feat: show username/password fields for Access database connections
This commit is contained in:
parent
2e021d37d1
commit
edba8214b5
|
|
@ -13,10 +13,7 @@ function manifestVersion(manifestJson) {
|
|||
return JSON.parse(manifestJson).version ?? "";
|
||||
}
|
||||
|
||||
export function evaluateJdbcPluginVersionChange({
|
||||
headPomVersion,
|
||||
headManifestVersion,
|
||||
}) {
|
||||
export function evaluateJdbcPluginVersionChange({ headPomVersion, headManifestVersion }) {
|
||||
const errors = [];
|
||||
if (headPomVersion !== headManifestVersion) {
|
||||
errors.push(`JDBC plugin version mismatch: pom.xml is ${headPomVersion} but manifest.json is ${headManifestVersion}.`);
|
||||
|
|
|
|||
|
|
@ -1,38 +1,35 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createHash } from "node:crypto";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const REPO = 't8y2/dbx';
|
||||
const GITHUB_TOKEN = process.env.GITHUB_TOKEN || '';
|
||||
const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY || '';
|
||||
const OUT_CN = 'releases-cn.json';
|
||||
const OUT_EN = 'releases-en.json';
|
||||
const EN_CACHE_URL = process.env.CHANGELOG_EN_CACHE_URL || 'https://dl.dbxio.com/changelog/releases-en.json';
|
||||
const REPO = "t8y2/dbx";
|
||||
const GITHUB_TOKEN = process.env.GITHUB_TOKEN || "";
|
||||
const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY || "";
|
||||
const OUT_CN = "releases-cn.json";
|
||||
const OUT_EN = "releases-en.json";
|
||||
const EN_CACHE_URL = process.env.CHANGELOG_EN_CACHE_URL || "https://dl.dbxio.com/changelog/releases-en.json";
|
||||
|
||||
const SECTION_MAP = {
|
||||
'新功能': 'added',
|
||||
'Added': 'added',
|
||||
'改进': 'improved',
|
||||
'Improved': 'improved',
|
||||
'修复': 'fixed',
|
||||
'Fixed': 'fixed',
|
||||
'变更': 'changed',
|
||||
'Changed': 'changed',
|
||||
'移除': 'removed',
|
||||
'Removed': 'removed',
|
||||
新功能: "added",
|
||||
Added: "added",
|
||||
改进: "improved",
|
||||
Improved: "improved",
|
||||
修复: "fixed",
|
||||
Fixed: "fixed",
|
||||
变更: "changed",
|
||||
Changed: "changed",
|
||||
移除: "removed",
|
||||
Removed: "removed",
|
||||
};
|
||||
|
||||
export async function fetchAllReleases() {
|
||||
const releases = [];
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const res = await fetch(
|
||||
`https://api.github.com/repos/${REPO}/releases?per_page=100&page=${page}`,
|
||||
{ headers: { Authorization: `token ${GITHUB_TOKEN}`, Accept: 'application/vnd.github+json' } },
|
||||
);
|
||||
const res = await fetch(`https://api.github.com/repos/${REPO}/releases?per_page=100&page=${page}`, { headers: { Authorization: `token ${GITHUB_TOKEN}`, Accept: "application/vnd.github+json" } });
|
||||
if (!res.ok) throw new Error(`GitHub API ${res.status}: ${await res.text()}`);
|
||||
const data = await res.json();
|
||||
if (data.length === 0) break;
|
||||
|
|
@ -43,7 +40,7 @@ export async function fetchAllReleases() {
|
|||
}
|
||||
|
||||
export function stripDownloadSection(body) {
|
||||
const markers = ['### 下载安装', '### Download', '### 系统要求', '### System Requirements'];
|
||||
const markers = ["### 下载安装", "### Download", "### 系统要求", "### System Requirements"];
|
||||
let idx = body.length;
|
||||
for (const m of markers) {
|
||||
const i = body.indexOf(m);
|
||||
|
|
@ -57,11 +54,11 @@ export function parseBody(body) {
|
|||
const sections = [];
|
||||
let current = null;
|
||||
|
||||
for (const line of cleaned.split('\n')) {
|
||||
for (const line of cleaned.split("\n")) {
|
||||
const headerMatch = line.match(/^###\s+(.+)/);
|
||||
if (headerMatch) {
|
||||
const title = headerMatch[1].trim();
|
||||
const type = SECTION_MAP[title] || 'other';
|
||||
const type = SECTION_MAP[title] || "other";
|
||||
current = { type, title, items: [] };
|
||||
sections.push(current);
|
||||
continue;
|
||||
|
|
@ -77,7 +74,7 @@ export function parseBody(body) {
|
|||
|
||||
const plainMatch = line.match(/^-\s+(.+)/);
|
||||
if (plainMatch) {
|
||||
current.items.push({ title: plainMatch[1].trim(), desc: '' });
|
||||
current.items.push({ title: plainMatch[1].trim(), desc: "" });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -85,16 +82,16 @@ export function parseBody(body) {
|
|||
}
|
||||
|
||||
export function buildReleaseSourceHash(release) {
|
||||
return createHash('sha256')
|
||||
return createHash("sha256")
|
||||
.update(
|
||||
JSON.stringify({
|
||||
tag: release.tag_name,
|
||||
name: release.name || release.tag_name,
|
||||
publishedAt: release.published_at || '',
|
||||
body: release.body || '',
|
||||
publishedAt: release.published_at || "",
|
||||
body: release.body || "",
|
||||
}),
|
||||
)
|
||||
.digest('hex');
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
export function buildReleasesJson(releases, now = new Date()) {
|
||||
|
|
@ -108,7 +105,7 @@ export function buildReleasesJson(releases, now = new Date()) {
|
|||
name: r.name || r.tag_name,
|
||||
date: r.published_at.slice(0, 10),
|
||||
_sourceHash: buildReleaseSourceHash(r),
|
||||
sections: parseBody(r.body || ''),
|
||||
sections: parseBody(r.body || ""),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
@ -116,18 +113,15 @@ export function buildReleasesJson(releases, now = new Date()) {
|
|||
function releaseToMarkdown(release) {
|
||||
return release.sections
|
||||
.map((s) => {
|
||||
const items = s.items.map((i) => (i.desc ? `- **${i.title}** — ${i.desc}` : `- ${i.title}`)).join('\n');
|
||||
const items = s.items.map((i) => (i.desc ? `- **${i.title}** — ${i.desc}` : `- ${i.title}`)).join("\n");
|
||||
return `### ${s.title}\n${items}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
export async function fetchCachedEnglish({
|
||||
cacheUrl = EN_CACHE_URL,
|
||||
fetchImpl = fetch,
|
||||
} = {}) {
|
||||
export async function fetchCachedEnglish({ cacheUrl = EN_CACHE_URL, fetchImpl = fetch } = {}) {
|
||||
try {
|
||||
const res = await fetchImpl(cacheUrl, { headers: { Accept: 'application/json' } });
|
||||
const res = await fetchImpl(cacheUrl, { headers: { Accept: "application/json" } });
|
||||
if (!res.ok) {
|
||||
console.warn(`English changelog cache unavailable: ${res.status}`);
|
||||
return null;
|
||||
|
|
@ -139,17 +133,9 @@ export async function fetchCachedEnglish({
|
|||
}
|
||||
}
|
||||
|
||||
export async function translateToEnglish(
|
||||
cnJson,
|
||||
{
|
||||
cachedEnJson = null,
|
||||
deepseekApiKey = DEEPSEEK_API_KEY,
|
||||
fetchImpl = fetch,
|
||||
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
||||
} = {},
|
||||
) {
|
||||
export async function translateToEnglish(cnJson, { cachedEnJson = null, deepseekApiKey = DEEPSEEK_API_KEY, fetchImpl = fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
|
||||
if (!deepseekApiKey) {
|
||||
console.warn('DEEPSEEK_API_KEY not set, skipping translation');
|
||||
console.warn("DEEPSEEK_API_KEY not set, skipping translation");
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -178,18 +164,18 @@ export async function translateToEnglish(
|
|||
continue;
|
||||
}
|
||||
|
||||
const res = await fetchImpl('https://api.deepseek.com/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${deepseekApiKey}` },
|
||||
const res = await fetchImpl("https://api.deepseek.com/chat/completions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${deepseekApiKey}` },
|
||||
body: JSON.stringify({
|
||||
model: 'deepseek-chat',
|
||||
model: "deepseek-chat",
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
role: "system",
|
||||
content:
|
||||
'You are a technical translator. Translate the following Chinese software changelog to English. Keep the exact markdown format (### headers, - bullet points, **bold** titles, — dashes). Only translate, do not add or remove content. Keep technical terms, product names, and contributor names unchanged.',
|
||||
"You are a technical translator. Translate the following Chinese software changelog to English. Keep the exact markdown format (### headers, - bullet points, **bold** titles, — dashes). Only translate, do not add or remove content. Keep technical terms, product names, and contributor names unchanged.",
|
||||
},
|
||||
{ role: 'user', content: sectionsText },
|
||||
{ role: "user", content: sectionsText },
|
||||
],
|
||||
temperature: 0.1,
|
||||
}),
|
||||
|
|
@ -202,7 +188,7 @@ export async function translateToEnglish(
|
|||
}
|
||||
|
||||
const data = await res.json();
|
||||
const translated = data.choices?.[0]?.message?.content || '';
|
||||
const translated = data.choices?.[0]?.message?.content || "";
|
||||
const enSections = parseBody(translated);
|
||||
enReleases.push({ ...release, sections: enSections.length > 0 ? enSections : release.sections });
|
||||
translatedCount++;
|
||||
|
|
@ -215,7 +201,7 @@ export async function translateToEnglish(
|
|||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Fetching releases from GitHub...');
|
||||
console.log("Fetching releases from GitHub...");
|
||||
const releases = await fetchAllReleases();
|
||||
console.log(`Found ${releases.length} releases`);
|
||||
|
||||
|
|
@ -225,17 +211,17 @@ async function main() {
|
|||
writeFileSync(OUT_CN, JSON.stringify(cnJson, null, 2));
|
||||
console.log(`Wrote ${OUT_CN}`);
|
||||
|
||||
console.log('Fetching cached English changelog...');
|
||||
console.log("Fetching cached English changelog...");
|
||||
const cachedEnJson = await fetchCachedEnglish();
|
||||
|
||||
console.log('Translating to English...');
|
||||
console.log("Translating to English...");
|
||||
const enJson = await translateToEnglish(cnJson, { cachedEnJson });
|
||||
if (enJson) {
|
||||
writeFileSync(OUT_EN, JSON.stringify(enJson, null, 2));
|
||||
console.log(`Wrote ${OUT_EN}`);
|
||||
}
|
||||
|
||||
console.log('Done!');
|
||||
console.log("Done!");
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ jobs:
|
|||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: './ -> target'
|
||||
workspaces: "./ -> target"
|
||||
shared-key: ci-x86_64-unknown-linux-gnu
|
||||
|
||||
- name: Cargo fmt check
|
||||
|
|
@ -89,7 +89,7 @@ jobs:
|
|||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
java-version: "17"
|
||||
cache: maven
|
||||
|
||||
- name: JDBC plugin version guard
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ name: Deploy Docs
|
|||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['docs/**']
|
||||
paths: ["docs/**"]
|
||||
workflow_run:
|
||||
workflows: ['Publish Packages']
|
||||
workflows: ["Publish Packages"]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ on:
|
|||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag (e.g. v0.3.10)'
|
||||
description: "Release tag (e.g. v0.3.10)"
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ name: Release
|
|||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
|
@ -107,7 +107,7 @@ jobs:
|
|||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: './ -> target'
|
||||
workspaces: "./ -> target"
|
||||
shared-key: release-${{ matrix.target }}-${{ steps.deps-hash.outputs.hash }}
|
||||
add-rust-environment-hash-key: false
|
||||
cache-on-failure: true
|
||||
|
|
@ -173,7 +173,7 @@ jobs:
|
|||
APPLE_TEAM_ID: ${{ startsWith(matrix.platform, 'macos') && secrets.APPLE_TEAM_ID || '' }}
|
||||
with:
|
||||
tagName: ${{ github.ref_name }}
|
||||
releaseName: 'DBX ${{ github.ref_name }}'
|
||||
releaseName: "DBX ${{ github.ref_name }}"
|
||||
releaseBody: ${{ steps.release-notes.outputs.body }}
|
||||
releaseDraft: true
|
||||
prerelease: false
|
||||
|
|
@ -246,7 +246,7 @@ jobs:
|
|||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
java-version: "17"
|
||||
cache: maven
|
||||
|
||||
- name: Apply automatic JDBC plugin version bump
|
||||
|
|
@ -302,7 +302,6 @@ jobs:
|
|||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release edit ${{ github.ref_name }} --repo ${{ github.repository }} --draft=false --prerelease
|
||||
|
||||
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ name: Sync Changelog to R2
|
|||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['Publish Packages']
|
||||
workflows: ["Publish Packages"]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"printWidth": 120,
|
||||
"printWidth": 300,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
|
|
|
|||
|
|
@ -258,13 +258,13 @@ pnpm tauri build
|
|||
|
||||
## 技术栈
|
||||
|
||||
| 层级 | 技术 |
|
||||
|------|------|
|
||||
| 框架 | [Tauri 2](https://tauri.app/) |
|
||||
| 前端 | [Vue 3](https://vuejs.org/) + TypeScript |
|
||||
| UI | [shadcn-vue](https://www.shadcn-vue.com/) + Tailwind CSS |
|
||||
| 编辑器 | [CodeMirror 6](https://codemirror.net/) |
|
||||
| 后端 | Rust + [sqlx](https://github.com/launchbadge/sqlx) / [tiberius](https://github.com/prisma/tiberius) / [redis-rs](https://github.com/redis-rs/redis-rs) / [mongodb](https://github.com/mongodb/mongo-rust-driver) |
|
||||
| 层级 | 技术 |
|
||||
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 框架 | [Tauri 2](https://tauri.app/) |
|
||||
| 前端 | [Vue 3](https://vuejs.org/) + TypeScript |
|
||||
| UI | [shadcn-vue](https://www.shadcn-vue.com/) + Tailwind CSS |
|
||||
| 编辑器 | [CodeMirror 6](https://codemirror.net/) |
|
||||
| 后端 | Rust + [sqlx](https://github.com/launchbadge/sqlx) / [tiberius](https://github.com/prisma/tiberius) / [redis-rs](https://github.com/redis-rs/redis-rs) / [mongodb](https://github.com/mongodb/mongo-rust-driver) |
|
||||
|
||||
## 社区
|
||||
|
||||
|
|
|
|||
|
|
@ -86,21 +86,7 @@ const settingsStore = useSettingsStore();
|
|||
const savedSqlStore = useSavedSqlStore();
|
||||
const { message: toastMessage, visible: toastVisible, toast } = useToast();
|
||||
const { isDark, themeMode, applyTheme, setThemeMode } = useTheme();
|
||||
const {
|
||||
checkingUpdates,
|
||||
updateInfo,
|
||||
updateCheckMessage,
|
||||
showUpdateDialog,
|
||||
isDownloadingUpdate,
|
||||
downloadProgress,
|
||||
updateReady,
|
||||
hasUpdateAvailable,
|
||||
openUrl,
|
||||
checkUpdates,
|
||||
openLatestRelease,
|
||||
downloadAndInstallUpdate,
|
||||
restartApp,
|
||||
} = useAppUpdater();
|
||||
const { checkingUpdates, updateInfo, updateCheckMessage, showUpdateDialog, isDownloadingUpdate, downloadProgress, updateReady, hasUpdateAvailable, openUrl, checkUpdates, openLatestRelease, downloadAndInstallUpdate, restartApp } = useAppUpdater();
|
||||
const { setupFileDrop } = useFileDrop();
|
||||
|
||||
const isDesktop = isTauriRuntime();
|
||||
|
|
@ -120,16 +106,7 @@ const showAiPanel = ref(safeLocalStorageGet("dbx-ai-panel-open") === "true");
|
|||
const showSqlLibraryPanel = ref(safeLocalStorageGet("dbx-sql-library-open") === "true");
|
||||
const sidebarOpen = ref(safeLocalStorageGet("dbx-sidebar-open") !== "false");
|
||||
const aiPanelReady = ref(false);
|
||||
const {
|
||||
sidebarWidth,
|
||||
aiPanelWidth,
|
||||
historyWidth,
|
||||
sqlLibraryWidth,
|
||||
startSidebarResize,
|
||||
startAiPanelResize,
|
||||
startHistoryResize,
|
||||
startSqlLibraryResize,
|
||||
} = usePanelResize();
|
||||
const { sidebarWidth, aiPanelWidth, historyWidth, sqlLibraryWidth, startSidebarResize, startAiPanelResize, startHistoryResize, startSqlLibraryResize } = usePanelResize();
|
||||
const aiAssistantRef = ref<AiAssistantHandle | null>(null);
|
||||
const appSidebarRef = ref<InstanceType<typeof AppSidebar> | null>(null);
|
||||
const contentAreaRef = ref<InstanceType<typeof ContentArea> | null>(null);
|
||||
|
|
@ -206,18 +183,7 @@ async function resolveActiveExecutableSql() {
|
|||
: "";
|
||||
}
|
||||
|
||||
const {
|
||||
dangerSql,
|
||||
pendingDangerSql,
|
||||
showDangerDialog,
|
||||
suppressDangerConfirm,
|
||||
tryExecute,
|
||||
doExecute,
|
||||
cancelActiveExecution,
|
||||
tryExplain,
|
||||
onDangerConfirm,
|
||||
explainMode,
|
||||
} = useSqlExecution({
|
||||
const { dangerSql, pendingDangerSql, showDangerDialog, suppressDangerConfirm, tryExecute, doExecute, cancelActiveExecution, tryExplain, onDangerConfirm, explainMode } = useSqlExecution({
|
||||
activeTab,
|
||||
activeConnection,
|
||||
executableSql,
|
||||
|
|
@ -227,8 +193,7 @@ const {
|
|||
|
||||
const dialogs = useDialogSources();
|
||||
const { getDatabaseOptions } = useDatabaseOptions();
|
||||
const { openLineageTarget, openDatabaseSearchTarget, onStructureEditorSaved, openTableTarget } =
|
||||
useNavigationTargets(dialogs);
|
||||
const { openLineageTarget, openDatabaseSearchTarget, onStructureEditorSaved, openTableTarget } = useNavigationTargets(dialogs);
|
||||
const { onExecuteSql, onReloadData, onPaginate, onSort } = useDataGridActions(activeTab);
|
||||
const { setupTauriListeners, cleanupTauriListeners } = useTauriEvents({
|
||||
openTableTarget,
|
||||
|
|
@ -241,13 +206,9 @@ useVisibilityChange();
|
|||
const appVersion = ref("");
|
||||
const isClassicLayout = computed(() => settingsStore.editorSettings.appLayout === "classic");
|
||||
const updateNotificationsEnabled = computed(() => settingsStore.editorSettings.updateNotificationsEnabled);
|
||||
const toolbarAgentDriverUpdateCount = computed(() =>
|
||||
updateNotificationsEnabled.value ? agentDriverUpdateCount.value : 0,
|
||||
);
|
||||
const toolbarAgentDriverUpdateCount = computed(() => (updateNotificationsEnabled.value ? agentDriverUpdateCount.value : 0));
|
||||
const toolbarHasUpdateAvailable = computed(() => updateNotificationsEnabled.value && hasUpdateAvailable.value);
|
||||
const hasSqlFileConnections = computed(() =>
|
||||
connectionStore.connections.some((c) => supportsSqlFileExecution(c.db_type)),
|
||||
);
|
||||
const hasSqlFileConnections = computed(() => connectionStore.connections.some((c) => supportsSqlFileExecution(c.db_type)));
|
||||
const connectionStats = computed(() => ({
|
||||
total: connectionStore.connections.length,
|
||||
connected: connectionStore.connectedIds.size,
|
||||
|
|
@ -290,11 +251,7 @@ function isGlobalUiZoomTarget(target: EventTarget | null): target is Element {
|
|||
if (target.closest("[data-query-editor-root], [data-cell-detail-editor-root], [data-object-source-editor]")) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
(target instanceof HTMLElement && target.isContentEditable)
|
||||
) {
|
||||
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || (target instanceof HTMLElement && target.isContentEditable)) {
|
||||
return false;
|
||||
}
|
||||
return !target.closest("[contenteditable='true']");
|
||||
|
|
@ -503,8 +460,7 @@ async function openSqlFilePath(path: string) {
|
|||
if (!isTauriRuntime()) return;
|
||||
try {
|
||||
const content = await api.readExternalSqlFile(path);
|
||||
const connectionId =
|
||||
connectionStore.activeConnectionId || activeTab.value?.connectionId || connectionStore.connections[0]?.id || "";
|
||||
const connectionId = connectionStore.activeConnectionId || activeTab.value?.connectionId || connectionStore.connections[0]?.id || "";
|
||||
const connection = connectionId ? connectionStore.getConfig(connectionId) : undefined;
|
||||
const database = activeTab.value?.database || (connection ? resolveDefaultDatabase(connection, []) : "");
|
||||
const tabId = queryStore.createTab(connectionId, database, sqlFileTitleFromPath(path), "query");
|
||||
|
|
@ -828,12 +784,7 @@ function handleKeydown(e: KeyboardEvent) {
|
|||
void openSaveSqlDialog();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
activeTab.value?.mode === "query" &&
|
||||
isExecuteSqlShortcut(e, shortcuts) &&
|
||||
e.target instanceof Element &&
|
||||
e.target.closest("[data-query-editor-root]")
|
||||
) {
|
||||
if (activeTab.value?.mode === "query" && isExecuteSqlShortcut(e, shortcuts) && e.target instanceof Element && e.target.closest("[data-query-editor-root]")) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
tryExecute();
|
||||
|
|
@ -1009,16 +960,10 @@ onUnmounted(() => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<LoginPage
|
||||
v-if="setupRequired || (needsAuth && !authenticated)"
|
||||
:setup-mode="setupRequired"
|
||||
@authenticated="onLoginSuccess"
|
||||
/>
|
||||
<LoginPage v-if="setupRequired || (needsAuth && !authenticated)" :setup-mode="setupRequired" @authenticated="onLoginSuccess" />
|
||||
<div v-show="!setupRequired && (!needsAuth || authenticated)" class="h-screen w-screen overflow-hidden">
|
||||
<TooltipProvider :delay-duration="300">
|
||||
<div
|
||||
class="h-screen w-screen max-w-full min-w-[760px] min-h-[600px] flex flex-col bg-background text-foreground overflow-hidden"
|
||||
>
|
||||
<div class="h-screen w-screen max-w-full min-w-[760px] min-h-[600px] flex flex-col bg-background text-foreground overflow-hidden">
|
||||
<AppToolbar
|
||||
:is-dark="isDark"
|
||||
:theme-mode="themeMode"
|
||||
|
|
@ -1047,60 +992,18 @@ onUnmounted(() => {
|
|||
@open-data-compare="dialogs.showDataCompareDialog.value = true"
|
||||
/>
|
||||
|
||||
<div
|
||||
:class="
|
||||
isClassicLayout
|
||||
? 'app-layout-classic flex-1 flex min-h-0'
|
||||
: 'app-panel-gutter flex-1 flex min-h-0 gap-1 p-1'
|
||||
"
|
||||
>
|
||||
<AppSidebar
|
||||
v-show="sidebarOpen"
|
||||
ref="appSidebarRef"
|
||||
:sidebar-width="sidebarWidth"
|
||||
:classic-layout="isClassicLayout"
|
||||
@import="dialogs.onImportClick"
|
||||
@export="dialogs.onExportClick"
|
||||
@start-resize="startSidebarResize"
|
||||
@collapse="setSidebarOpen(false)"
|
||||
/>
|
||||
<div
|
||||
v-show="!sidebarOpen"
|
||||
class="flex h-full w-8 shrink-0 items-start justify-center border-r bg-background/80 pt-2"
|
||||
:class="isClassicLayout ? '' : 'rounded-md border border-border/80'"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
:title="t('sidebar.expand')"
|
||||
:aria-label="t('sidebar.expand')"
|
||||
@click="setSidebarOpen(true)"
|
||||
>
|
||||
<div :class="isClassicLayout ? 'app-layout-classic flex-1 flex min-h-0' : 'app-panel-gutter flex-1 flex min-h-0 gap-1 p-1'">
|
||||
<AppSidebar v-show="sidebarOpen" ref="appSidebarRef" :sidebar-width="sidebarWidth" :classic-layout="isClassicLayout" @import="dialogs.onImportClick" @export="dialogs.onExportClick" @start-resize="startSidebarResize" @collapse="setSidebarOpen(false)" />
|
||||
<div v-show="!sidebarOpen" class="flex h-full w-8 shrink-0 items-start justify-center border-r bg-background/80 pt-2" :class="isClassicLayout ? '' : 'rounded-md border border-border/80'">
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('sidebar.expand')" :aria-label="t('sidebar.expand')" @click="setSidebarOpen(true)">
|
||||
<ChevronsRight class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:class="
|
||||
isClassicLayout
|
||||
? 'flex-1 min-w-0 overflow-hidden'
|
||||
: 'flex-1 min-w-0 overflow-hidden rounded-md border border-border/80 bg-background'
|
||||
"
|
||||
>
|
||||
<div :class="isClassicLayout ? 'flex-1 min-w-0 overflow-hidden' : 'flex-1 min-w-0 overflow-hidden rounded-md border border-border/80 bg-background'">
|
||||
<div class="h-full flex flex-col min-w-0">
|
||||
<AppTabBar
|
||||
:show-driver-store="showDriverStore"
|
||||
:agent-driver-update-count="toolbarAgentDriverUpdateCount"
|
||||
@toggle-driver-store="showDriverStore = true"
|
||||
@close-driver-store="showDriverStore = false"
|
||||
/>
|
||||
<DriverStorePage
|
||||
v-if="showDriverStore"
|
||||
class="flex-1 min-h-0"
|
||||
:update-notifications-enabled="updateNotificationsEnabled"
|
||||
@update-count-change="updateAgentDriverUpdateCount"
|
||||
/>
|
||||
<AppTabBar :show-driver-store="showDriverStore" :agent-driver-update-count="toolbarAgentDriverUpdateCount" @toggle-driver-store="showDriverStore = true" @close-driver-store="showDriverStore = false" />
|
||||
<DriverStorePage v-if="showDriverStore" class="flex-1 min-h-0" :update-notifications-enabled="updateNotificationsEnabled" @update-count-change="updateAgentDriverUpdateCount" />
|
||||
<div v-else-if="activeTab" class="flex flex-col flex-1 min-h-0">
|
||||
<EditorToolbar
|
||||
v-if="activeTab.mode === 'query' && !isPreviewTab(activeTab)"
|
||||
|
|
@ -1140,26 +1043,11 @@ onUnmounted(() => {
|
|||
@editor-update="(tabId: string, v: string) => queryStore.updateSql(tabId, v)"
|
||||
@editor-selection-change="(v: string) => (selectedSql = v)"
|
||||
@editor-cursor-change="(p: number) => (cursorPos = p)"
|
||||
@editor-viewport-change="
|
||||
(tabId: string, viewport: { scrollTop: number; scrollLeft: number }) =>
|
||||
queryStore.updateEditorViewport(tabId, viewport)
|
||||
"
|
||||
@editor-selection-state-change="
|
||||
(tabId: string, selection: { anchor: number; head: number }) =>
|
||||
queryStore.updateEditorSelection(tabId, selection)
|
||||
"
|
||||
@editor-viewport-change="(tabId: string, viewport: { scrollTop: number; scrollLeft: number }) => queryStore.updateEditorViewport(tabId, viewport)"
|
||||
@editor-selection-state-change="(tabId: string, selection: { anchor: number; head: number }) => queryStore.updateEditorSelection(tabId, selection)"
|
||||
@format-error="toast(t('toolbar.formatSqlFailed'))"
|
||||
@save-sql="void openSaveSqlDialog()"
|
||||
@reload="
|
||||
(
|
||||
sql?: string,
|
||||
searchText?: string,
|
||||
whereInput?: string,
|
||||
orderBy?: string,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
) => onReloadData(sql, searchText, whereInput, orderBy, limit, offset)
|
||||
"
|
||||
@reload="(sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number) => onReloadData(sql, searchText, whereInput, orderBy, limit, offset)"
|
||||
@paginate="onPaginate"
|
||||
@sort="onSort"
|
||||
@execute-sql="onExecuteSql"
|
||||
|
|
@ -1211,56 +1099,19 @@ onUnmounted(() => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showAiPanel"
|
||||
:class="
|
||||
isClassicLayout
|
||||
? 'h-full shrink-0 relative z-30 isolate bg-background'
|
||||
: 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'
|
||||
"
|
||||
:style="{ width: aiPanelWidth + 'px' }"
|
||||
>
|
||||
<div v-if="showAiPanel" :class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'" :style="{ width: aiPanelWidth + 'px' }">
|
||||
<div class="panel-resize-handle panel-resize-handle--left" @mousedown="startAiPanelResize" />
|
||||
<div class="h-full min-h-0 overflow-hidden">
|
||||
<AiAssistant
|
||||
v-if="aiPanelReady"
|
||||
ref="aiAssistantRef"
|
||||
:tab="activeTab"
|
||||
:connection="activeConnection"
|
||||
@replace-sql="onAiReplaceSql"
|
||||
@execute-sql="onAiExecuteSql"
|
||||
@request-auto-execute-sql="onAiRequestAutoExecuteSql"
|
||||
@close="toggleAiPanel"
|
||||
/>
|
||||
<AiAssistant v-if="aiPanelReady" ref="aiAssistantRef" :tab="activeTab" :connection="activeConnection" @replace-sql="onAiReplaceSql" @execute-sql="onAiExecuteSql" @request-auto-execute-sql="onAiRequestAutoExecuteSql" @close="toggleAiPanel" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showHistory"
|
||||
:class="
|
||||
isClassicLayout
|
||||
? 'h-full shrink-0 relative z-30 isolate bg-background'
|
||||
: 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'
|
||||
"
|
||||
:style="{ width: historyWidth + 'px' }"
|
||||
>
|
||||
<div v-if="showHistory" :class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'" :style="{ width: historyWidth + 'px' }">
|
||||
<div class="panel-resize-handle panel-resize-handle--left" @mousedown="startHistoryResize" />
|
||||
<QueryHistory
|
||||
@restore="restoreHistorySql"
|
||||
@analyze-ai="analyzeHistoryWithAi"
|
||||
@close="showHistory = false"
|
||||
/>
|
||||
<QueryHistory @restore="restoreHistorySql" @analyze-ai="analyzeHistoryWithAi" @close="showHistory = false" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showSqlLibraryPanel"
|
||||
:class="
|
||||
isClassicLayout
|
||||
? 'h-full shrink-0 relative z-30 isolate bg-background'
|
||||
: 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'
|
||||
"
|
||||
:style="{ width: sqlLibraryWidth + 'px' }"
|
||||
>
|
||||
<div v-if="showSqlLibraryPanel" :class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'" :style="{ width: sqlLibraryWidth + 'px' }">
|
||||
<div class="panel-resize-handle panel-resize-handle--left" @mousedown="startSqlLibraryResize" />
|
||||
<div class="h-full min-h-0 overflow-hidden">
|
||||
<SqlLibraryPanel @close="showSqlLibraryPanel = false" />
|
||||
|
|
@ -1283,9 +1134,7 @@ onUnmounted(() => {
|
|||
@danger-confirm="onDangerConfirm"
|
||||
@connect-started="(name: string) => toast(t('connection.connecting', { name }), 30000)"
|
||||
@connect-succeeded="(name: string) => toast(t('connection.connectSuccess', { name }), 2000)"
|
||||
@connect-failed="
|
||||
(msg: string) => toast(t('connection.connectFailed', { message: translateBackendError(t, msg) }), 5000)
|
||||
"
|
||||
@connect-failed="(msg: string) => toast(t('connection.connectFailed', { message: translateBackendError(t, msg) }), 5000)"
|
||||
@open-driver-store="
|
||||
setConnectionDialogOpen(false);
|
||||
showDriverStore = true;
|
||||
|
|
@ -1306,10 +1155,7 @@ onUnmounted(() => {
|
|||
@restart="restartApp"
|
||||
/>
|
||||
<Transition name="toast">
|
||||
<div
|
||||
v-if="toastVisible"
|
||||
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-100 px-4 py-2 rounded-lg bg-foreground text-background text-sm shadow-lg"
|
||||
>
|
||||
<div v-if="toastVisible" class="fixed bottom-6 left-1/2 -translate-x-1/2 z-100 px-4 py-2 rounded-lg bg-foreground text-background text-sm shadow-lg">
|
||||
{{ toastMessage }}
|
||||
</div>
|
||||
</Transition>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
KeyRound,
|
||||
Lock,
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCcw,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
Unlock,
|
||||
UserRound,
|
||||
} from "@lucide/vue";
|
||||
import { AlertTriangle, Check, KeyRound, Lock, Loader2, Plus, RefreshCcw, Search, ShieldCheck, Trash2, Unlock, UserRound } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
|
@ -25,13 +12,7 @@ import { useToast } from "@/composables/useToast";
|
|||
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
import * as api from "@/lib/api";
|
||||
import {
|
||||
grantsFromQueryResult,
|
||||
getDatabaseUserAdminProvider,
|
||||
supportsDatabaseUserAdmin,
|
||||
type DatabaseUserIdentity,
|
||||
type PrivilegeScope,
|
||||
} from "@/lib/databaseUserAdmin";
|
||||
import { grantsFromQueryResult, getDatabaseUserAdminProvider, supportsDatabaseUserAdmin, type DatabaseUserIdentity, type PrivilegeScope } from "@/lib/databaseUserAdmin";
|
||||
|
||||
const props = defineProps<{
|
||||
connection: ConnectionConfig;
|
||||
|
|
@ -119,29 +100,15 @@ async function loadUsers() {
|
|||
await ensureConnection();
|
||||
let nextUsers: DatabaseUserIdentity[] = [];
|
||||
try {
|
||||
const result = await api.executeQuery(
|
||||
props.connection.id,
|
||||
"",
|
||||
userProvider.listUsersSql(),
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
maxRows: 5000,
|
||||
},
|
||||
);
|
||||
const result = await api.executeQuery(props.connection.id, "", userProvider.listUsersSql(), undefined, undefined, {
|
||||
maxRows: 5000,
|
||||
});
|
||||
nextUsers = userProvider.parseUsers(result);
|
||||
} catch (error) {
|
||||
if (!userProvider.fallbackListUsersSql || !userProvider.parseFallbackUsers) throw error;
|
||||
const fallback = await api.executeQuery(
|
||||
props.connection.id,
|
||||
"",
|
||||
userProvider.fallbackListUsersSql(),
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
maxRows: 5000,
|
||||
},
|
||||
);
|
||||
const fallback = await api.executeQuery(props.connection.id, "", userProvider.fallbackListUsersSql(), undefined, undefined, {
|
||||
maxRows: 5000,
|
||||
});
|
||||
nextUsers = userProvider.parseFallbackUsers(fallback);
|
||||
}
|
||||
users.value = nextUsers;
|
||||
|
|
@ -163,16 +130,9 @@ async function loadGrants() {
|
|||
loadingGrants.value = true;
|
||||
grantError.value = "";
|
||||
try {
|
||||
const result = await api.executeQuery(
|
||||
props.connection.id,
|
||||
"",
|
||||
userProvider.showGrantsSql(user),
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
maxRows: 1000,
|
||||
},
|
||||
);
|
||||
const result = await api.executeQuery(props.connection.id, "", userProvider.showGrantsSql(user), undefined, undefined, {
|
||||
maxRows: 1000,
|
||||
});
|
||||
grants.value = grantsFromQueryResult(result);
|
||||
} catch (error: any) {
|
||||
grantError.value = error?.message || String(error);
|
||||
|
|
@ -364,10 +324,7 @@ onMounted(loadUsers);
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!supported"
|
||||
class="flex flex-1 items-center justify-center px-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
<div v-if="!supported" class="flex flex-1 items-center justify-center px-6 text-center text-sm text-muted-foreground">
|
||||
{{ t("userAdmin.unsupported") }}
|
||||
</div>
|
||||
|
||||
|
|
@ -376,11 +333,7 @@ onMounted(loadUsers);
|
|||
<div class="flex h-12 items-center border-b px-2">
|
||||
<div class="flex h-8 items-center gap-2 rounded-md border bg-background px-2">
|
||||
<Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
v-model="search"
|
||||
class="min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground"
|
||||
:placeholder="t('userAdmin.searchUser')"
|
||||
/>
|
||||
<input v-model="search" class="min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground" :placeholder="t('userAdmin.searchUser')" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto">
|
||||
|
|
@ -400,18 +353,12 @@ onMounted(loadUsers);
|
|||
<UserRound class="h-4 w-4" />
|
||||
<span class="min-w-0">
|
||||
<span class="block truncate font-medium">{{ userLabel(user) || t("userAdmin.anonymous") }}</span>
|
||||
<span
|
||||
v-if="userDetail(user)"
|
||||
class="mt-1 inline-flex max-w-full rounded-full border bg-muted/40 px-1.5 py-0.5 text-[10px] leading-none text-muted-foreground"
|
||||
>
|
||||
<span v-if="userDetail(user)" class="mt-1 inline-flex max-w-full rounded-full border bg-muted/40 px-1.5 py-0.5 text-[10px] leading-none text-muted-foreground">
|
||||
<span class="truncate">{{ userDetail(user) }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="!loadingUsers && !loadError && filteredUsers.length === 0"
|
||||
class="px-3 py-8 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
<div v-if="!loadingUsers && !loadError && filteredUsers.length === 0" class="px-3 py-8 text-center text-xs text-muted-foreground">
|
||||
{{ t("grid.noSearchResults") }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -424,11 +371,7 @@ onMounted(loadUsers);
|
|||
</div>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<div class="truncate text-sm font-semibold">{{ userLabel(selectedUser) }}</div>
|
||||
<Badge
|
||||
v-if="selectedDetail"
|
||||
variant="outline"
|
||||
class="h-5 max-w-[180px] rounded-full px-2 py-0 text-[10px] font-normal"
|
||||
>
|
||||
<Badge v-if="selectedDetail" variant="outline" class="h-5 max-w-[180px] rounded-full px-2 py-0 text-[10px] font-normal">
|
||||
<span class="truncate">{{ selectedDetail }}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
|
|
@ -464,11 +407,7 @@ onMounted(loadUsers);
|
|||
{{ t("userAdmin.loadingGrants") }}
|
||||
</div>
|
||||
<div v-else-if="grantError" class="text-xs text-destructive">{{ grantError }}</div>
|
||||
<pre
|
||||
v-else
|
||||
class="min-h-full whitespace-pre-wrap rounded-md bg-muted/30 p-3 font-mono text-xs leading-5 text-foreground"
|
||||
v-html="highlightedGrantsSql"
|
||||
/>
|
||||
<pre v-else class="min-h-full whitespace-pre-wrap rounded-md bg-muted/30 p-3 font-mono text-xs leading-5 text-foreground" v-html="highlightedGrantsSql" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -501,11 +440,7 @@ onMounted(loadUsers);
|
|||
<label class="mb-2 block text-xs font-medium">
|
||||
{{ isPostgres && privilegeScope !== "database" ? t("userAdmin.schema") : t("userAdmin.database") }}
|
||||
</label>
|
||||
<Input
|
||||
v-model="privilegeDatabase"
|
||||
class="mb-3 h-8 text-xs"
|
||||
:placeholder="isPostgres ? 'public' : '*'"
|
||||
/>
|
||||
<Input v-model="privilegeDatabase" class="mb-3 h-8 text-xs" :placeholder="isPostgres ? 'public' : '*'" />
|
||||
<template v-if="!isPostgres || privilegeScope === 'table'">
|
||||
<label class="mb-2 block text-xs font-medium">{{ t("userAdmin.table") }}</label>
|
||||
<Input v-model="privilegeTable" class="mb-3 h-8 text-xs" placeholder="*" />
|
||||
|
|
@ -519,19 +454,10 @@ onMounted(loadUsers);
|
|||
:key="privilege"
|
||||
type="button"
|
||||
class="flex h-7 items-center gap-1.5 rounded-md border px-2 text-left text-[11px] hover:bg-accent"
|
||||
:class="
|
||||
selectedPrivilegeSet.has(privilege) ? 'border-primary bg-primary/10 text-primary' : 'bg-background'
|
||||
"
|
||||
:class="selectedPrivilegeSet.has(privilege) ? 'border-primary bg-primary/10 text-primary' : 'bg-background'"
|
||||
@click="togglePrivilege(privilege)"
|
||||
>
|
||||
<span
|
||||
class="flex h-3.5 w-3.5 items-center justify-center rounded border"
|
||||
:class="
|
||||
selectedPrivilegeSet.has(privilege)
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-border'
|
||||
"
|
||||
>
|
||||
<span class="flex h-3.5 w-3.5 items-center justify-center rounded border" :class="selectedPrivilegeSet.has(privilege) ? 'border-primary bg-primary text-primary-foreground' : 'border-border'">
|
||||
<Check v-if="selectedPrivilegeSet.has(privilege)" class="h-2.5 w-2.5" />
|
||||
</span>
|
||||
<span class="truncate">{{ privilege }}</span>
|
||||
|
|
@ -608,10 +534,7 @@ onMounted(loadUsers);
|
|||
{{ t("userAdmin.sqlPreview") }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<pre
|
||||
class="max-h-[50vh] min-h-44 overflow-auto whitespace-pre-wrap rounded-md border bg-muted/30 p-3 font-mono text-xs leading-5"
|
||||
v-html="highlightedPendingSql"
|
||||
/>
|
||||
<pre class="max-h-[50vh] min-h-44 overflow-auto whitespace-pre-wrap rounded-md border bg-muted/30 p-3 font-mono text-xs leading-5" v-html="highlightedPendingSql" />
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="sqlDialogOpen = false">{{ t("dangerDialog.cancel") }}</Button>
|
||||
<Button :variant="pendingDanger ? 'destructive' : 'default'" :disabled="applying" @click="applyPendingSql">
|
||||
|
|
|
|||
|
|
@ -51,9 +51,7 @@ async function submit() {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-center h-screen bg-gradient-to-br from-background via-background to-blue-950/20"
|
||||
>
|
||||
<div class="flex items-center justify-center h-screen bg-gradient-to-br from-background via-background to-blue-950/20">
|
||||
<div class="w-[360px] space-y-8">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<AppLogo class="w-20 h-20 rounded-2xl shadow-lg shadow-blue-500/20" />
|
||||
|
|
@ -72,31 +70,14 @@ async function submit() {
|
|||
</div>
|
||||
<div class="relative">
|
||||
<Lock class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
v-model="password"
|
||||
type="password"
|
||||
:placeholder="setupMode ? t('auth.newPassword') : t('auth.enterPassword')"
|
||||
class="pl-10 h-11"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
/>
|
||||
<Input v-model="password" type="password" :placeholder="setupMode ? t('auth.newPassword') : t('auth.enterPassword')" class="pl-10 h-11" autocomplete="off" autofocus />
|
||||
</div>
|
||||
<div v-if="setupMode" class="relative">
|
||||
<Lock class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
:placeholder="t('auth.confirmPassword')"
|
||||
class="pl-10 h-11"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<Input v-model="confirmPassword" type="password" :placeholder="t('auth.confirmPassword')" class="pl-10 h-11" autocomplete="off" />
|
||||
</div>
|
||||
<p v-if="error" class="text-sm text-destructive text-center">{{ error }}</p>
|
||||
<Button
|
||||
type="submit"
|
||||
class="w-full h-11 text-sm font-medium"
|
||||
:disabled="loading || !password || (setupMode && !confirmPassword)"
|
||||
>
|
||||
<Button type="submit" class="w-full h-11 text-sm font-medium" :disabled="loading || !password || (setupMode && !confirmPassword)">
|
||||
<Loader2 v-if="loading" class="w-4 h-4 animate-spin mr-2" />
|
||||
{{ loading ? t("auth.processing") : setupMode ? t("auth.setPassword") : t("auth.login") }}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -26,9 +26,7 @@ const chartType = ref<ChartType>("bar");
|
|||
const xColumn = ref("");
|
||||
const yColumns = ref<string[]>([]);
|
||||
|
||||
const numericColumns = computed(() =>
|
||||
props.result.columns.filter((_, idx) => props.result.rows.some((row) => typeof row[idx] === "number")),
|
||||
);
|
||||
const numericColumns = computed(() => props.result.columns.filter((_, idx) => props.result.rows.some((row) => typeof row[idx] === "number")));
|
||||
|
||||
const allColumns = computed(() => props.result.columns);
|
||||
|
||||
|
|
@ -118,14 +116,7 @@ const hasData = computed(() => props.result.rows.length > 0 && numericColumns.va
|
|||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-muted-foreground">{{ t("chart.type") }}</span>
|
||||
<div class="flex gap-0.5">
|
||||
<Button
|
||||
v-for="ct in ['bar', 'line', 'pie'] as ChartType[]"
|
||||
:key="ct"
|
||||
size="sm"
|
||||
:variant="chartType === ct ? 'secondary' : 'ghost'"
|
||||
class="h-6 px-2 text-xs"
|
||||
@click="chartType = ct"
|
||||
>
|
||||
<Button v-for="ct in ['bar', 'line', 'pie'] as ChartType[]" :key="ct" size="sm" :variant="chartType === ct ? 'secondary' : 'ghost'" class="h-6 px-2 text-xs" @click="chartType = ct">
|
||||
{{ t(`chart.${ct}`) }}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -146,14 +137,7 @@ const hasData = computed(() => props.result.rows.length > 0 && numericColumns.va
|
|||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-muted-foreground">Y</span>
|
||||
<div class="flex gap-0.5">
|
||||
<Button
|
||||
v-for="col in numericColumns"
|
||||
:key="col"
|
||||
size="sm"
|
||||
:variant="yColumns.includes(col) ? 'secondary' : 'ghost'"
|
||||
class="h-6 px-2 text-xs"
|
||||
@click="toggleYColumn(col)"
|
||||
>
|
||||
<Button v-for="col in numericColumns" :key="col" size="sm" :variant="yColumns.includes(col) ? 'secondary' : 'ghost'" class="h-6 px-2 text-xs" @click="toggleYColumn(col)">
|
||||
{{ col }}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -76,22 +76,12 @@ const displayError = computed(() => error.value || props.externalError || "");
|
|||
|
||||
<div class="grid gap-2">
|
||||
<Label>{{ t("configExport.passphrase") }}</Label>
|
||||
<Input
|
||||
v-model="passphrase"
|
||||
type="password"
|
||||
:placeholder="t('configExport.passphrasePlaceholder')"
|
||||
@keydown.enter="mode === 'import' ? confirm() : undefined"
|
||||
/>
|
||||
<Input v-model="passphrase" type="password" :placeholder="t('configExport.passphrasePlaceholder')" @keydown.enter="mode === 'import' ? confirm() : undefined" />
|
||||
</div>
|
||||
|
||||
<div v-if="mode === 'export'" class="grid gap-2">
|
||||
<Label>{{ t("configExport.passphraseConfirm") }}</Label>
|
||||
<Input
|
||||
v-model="passphraseConfirm"
|
||||
type="password"
|
||||
:placeholder="t('configExport.passphraseConfirmPlaceholder')"
|
||||
@keydown.enter="confirm"
|
||||
/>
|
||||
<Input v-model="passphraseConfirm" type="password" :placeholder="t('configExport.passphraseConfirmPlaceholder')" @keydown.enter="confirm" />
|
||||
</div>
|
||||
|
||||
<p v-if="displayError" class="text-sm text-destructive">{{ displayError }}</p>
|
||||
|
|
|
|||
|
|
@ -6,27 +6,10 @@ const props = defineProps<{
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="relative flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
|
||||
role="status"
|
||||
:aria-label="title"
|
||||
:title="title"
|
||||
>
|
||||
<span class="relative flex h-8 w-8 shrink-0 items-center justify-center rounded-full" role="status" :aria-label="title" :title="title">
|
||||
<svg class="absolute inset-0 h-8 w-8 -rotate-90" viewBox="0 0 32 32" aria-hidden="true">
|
||||
<circle class="text-green-600/20" cx="16" cy="16" r="13" fill="none" stroke="currentColor" stroke-width="3" />
|
||||
<circle
|
||||
class="text-green-600 transition-[stroke-dashoffset] duration-200"
|
||||
cx="16"
|
||||
cy="16"
|
||||
r="13"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
pathLength="100"
|
||||
stroke-dasharray="100"
|
||||
:stroke-dashoffset="100 - (props.percent ?? 0)"
|
||||
/>
|
||||
<circle class="text-green-600 transition-[stroke-dashoffset] duration-200" cx="16" cy="16" r="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" pathLength="100" stroke-dasharray="100" :stroke-dashoffset="100 - (props.percent ?? 0)" />
|
||||
</svg>
|
||||
<span class="relative z-10 font-mono text-[10px] font-semibold leading-none text-green-700 tabular-nums">
|
||||
{{ props.percent ?? "..." }}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
Activity,
|
||||
ExternalLink,
|
||||
Cpu,
|
||||
FolderOpen,
|
||||
MemoryStick,
|
||||
Search,
|
||||
Square,
|
||||
Trash2,
|
||||
Download,
|
||||
RotateCcw,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Check,
|
||||
Clock3,
|
||||
FileUp,
|
||||
} from "@lucide/vue";
|
||||
import { Activity, ExternalLink, Cpu, FolderOpen, MemoryStick, Search, Square, Trash2, Download, RotateCcw, Loader2, RefreshCw, Check, Clock3, FileUp } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -30,29 +14,9 @@ import { isTauriRuntime } from "@/lib/tauriRuntime";
|
|||
import { countAvailableDriverUpdates } from "@/lib/agentDriverUpdateBadge";
|
||||
import type { JdbcDriverInfo, JdbcPluginStatus } from "@/types/database";
|
||||
import * as api from "@/lib/api";
|
||||
import type {
|
||||
AgentDriverInfo,
|
||||
DriverRuntimeInfo,
|
||||
DriverRuntimeSummary,
|
||||
DriverStoreUsage,
|
||||
JavaRuntimeConfig,
|
||||
} from "@/lib/api";
|
||||
import {
|
||||
formatRuntimeBytes,
|
||||
formatRuntimeCpu,
|
||||
formatRuntimeUptime,
|
||||
runtimeHealthClass,
|
||||
runtimeStatusClass,
|
||||
runtimeStatusDotClass,
|
||||
} from "@/lib/driverRuntimePresentation";
|
||||
import {
|
||||
addDriverInstallQueue,
|
||||
driverInstallProgressPercent,
|
||||
isDriverInstallProgressTarget,
|
||||
removeDriverInstallQueue,
|
||||
takeNextDriverInstallQueue,
|
||||
type DriverInstallProgress,
|
||||
} from "@/lib/driverInstallProgressUi";
|
||||
import type { AgentDriverInfo, DriverRuntimeInfo, DriverRuntimeSummary, DriverStoreUsage, JavaRuntimeConfig } from "@/lib/api";
|
||||
import { formatRuntimeBytes, formatRuntimeCpu, formatRuntimeUptime, runtimeHealthClass, runtimeStatusClass, runtimeStatusDotClass } from "@/lib/driverRuntimePresentation";
|
||||
import { addDriverInstallQueue, driverInstallProgressPercent, isDriverInstallProgressTarget, removeDriverInstallQueue, takeNextDriverInstallQueue, type DriverInstallProgress } from "@/lib/driverInstallProgressUi";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
|
|
@ -107,9 +71,7 @@ const installedJres = computed(() => {
|
|||
jreMap.set(d.jre, d.jre_installed);
|
||||
}
|
||||
}
|
||||
return [...jreMap.entries()]
|
||||
.map(([key, installed]) => ({ key, installed }))
|
||||
.sort((a, b) => b.key.localeCompare(a.key));
|
||||
return [...jreMap.entries()].map(([key, installed]) => ({ key, installed })).sort((a, b) => b.key.localeCompare(a.key));
|
||||
});
|
||||
|
||||
const progressText = computed(() => {
|
||||
|
|
@ -121,18 +83,13 @@ const progressText = computed(() => {
|
|||
const pct = Math.round(((p.downloaded ?? 0) / p.total) * 100);
|
||||
const dl = formatSize(p.downloaded ?? 0);
|
||||
const total = formatSize(p.total);
|
||||
const prefix =
|
||||
upgradingAll.value && upgradingCurrent.value
|
||||
? `[${upgradingIndex.value}/${upgradingTotal.value}] ${upgradingCurrent.value} — `
|
||||
: "";
|
||||
const prefix = upgradingAll.value && upgradingCurrent.value ? `[${upgradingIndex.value}/${upgradingTotal.value}] ${upgradingCurrent.value} — ` : "";
|
||||
return `${prefix}${label} ${dl} / ${total} (${pct}%)`;
|
||||
});
|
||||
|
||||
const progressNumber = computed(() => driverInstallProgressPercent(progress.value));
|
||||
|
||||
const updatableCount = computed(() =>
|
||||
props.updateNotificationsEnabled ? drivers.value.filter((d) => d.update_available).length : 0,
|
||||
);
|
||||
const updatableCount = computed(() => (props.updateNotificationsEnabled ? drivers.value.filter((d) => d.update_available).length : 0));
|
||||
const usageSummary = computed(() => {
|
||||
const usage = driverStoreUsage.value;
|
||||
if (!usage) return [];
|
||||
|
|
@ -157,12 +114,8 @@ function updateAgentDrivers(nextDrivers: AgentDriverInfo[]) {
|
|||
emitDriverUpdateCount();
|
||||
}
|
||||
|
||||
const agentTabUpdateCount = computed(() =>
|
||||
props.updateNotificationsEnabled ? drivers.value.filter((d) => d.update_available).length : 0,
|
||||
);
|
||||
const jdbcTabUpdateCount = computed(() =>
|
||||
props.updateNotificationsEnabled && jdbcPluginStatus.value?.update_available ? 1 : 0,
|
||||
);
|
||||
const agentTabUpdateCount = computed(() => (props.updateNotificationsEnabled ? drivers.value.filter((d) => d.update_available).length : 0));
|
||||
const jdbcTabUpdateCount = computed(() => (props.updateNotificationsEnabled && jdbcPluginStatus.value?.update_available ? 1 : 0));
|
||||
|
||||
function emitDriverUpdateCount() {
|
||||
if (!props.updateNotificationsEnabled) {
|
||||
|
|
@ -321,9 +274,7 @@ async function upgradeAll() {
|
|||
const result = await api.upgradeAllAgents();
|
||||
await refreshAgents();
|
||||
if (result.failed.length > 0) {
|
||||
const failedLabels = result.failed
|
||||
.map((item) => drivers.value.find((driver) => driver.db_type === item.db_type)?.label ?? item.db_type)
|
||||
.join(", ");
|
||||
const failedLabels = result.failed.map((item) => drivers.value.find((driver) => driver.db_type === item.db_type)?.label ?? item.db_type).join(", ");
|
||||
toast(t("driverStore.upgradeAllPartial", { count: result.upgraded, failed: failedLabels }));
|
||||
} else {
|
||||
toast(t("driverStore.upgradeAllSuccess", { count: result.upgraded }));
|
||||
|
|
@ -493,21 +444,13 @@ const jdbcDriverPathInput = ref("");
|
|||
const filteredAgentDrivers = computed(() => {
|
||||
const query = agentDriverSearch.value.trim().toLowerCase();
|
||||
if (!query) return drivers.value;
|
||||
return drivers.value.filter((driver) =>
|
||||
[driver.label, driver.db_type, driver.version, driver.installed_version, driver.jre]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(query),
|
||||
);
|
||||
return drivers.value.filter((driver) => [driver.label, driver.db_type, driver.version, driver.installed_version, driver.jre].filter(Boolean).join(" ").toLowerCase().includes(query));
|
||||
});
|
||||
|
||||
const filteredJdbcDrivers = computed(() => {
|
||||
const query = jdbcDriverSearch.value.trim().toLowerCase();
|
||||
if (!query) return jdbcDrivers.value;
|
||||
return jdbcDrivers.value.filter((driver) =>
|
||||
[driver.name, driver.path, String(driver.size)].join(" ").toLowerCase().includes(query),
|
||||
);
|
||||
return jdbcDrivers.value.filter((driver) => [driver.name, driver.path, String(driver.size)].join(" ").toLowerCase().includes(query));
|
||||
});
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
|
|
@ -543,9 +486,7 @@ function runtimeKindLabel(runtime: DriverRuntimeInfo) {
|
|||
}
|
||||
|
||||
function runtimeSourceLabel(runtime: DriverRuntimeInfo) {
|
||||
return runtime.source === "connection"
|
||||
? t("driverStore.runtimeSourceConnection")
|
||||
: t("driverStore.runtimeSourceDaemon");
|
||||
return runtime.source === "connection" ? t("driverStore.runtimeSourceConnection") : t("driverStore.runtimeSourceDaemon");
|
||||
}
|
||||
|
||||
function runtimeStatusLabel(status: DriverRuntimeInfo["status"]) {
|
||||
|
|
@ -744,9 +685,7 @@ async function importJdbcDrivers() {
|
|||
});
|
||||
if (!selected) return;
|
||||
|
||||
const paths = (Array.isArray(selected) ? selected : [selected]).filter(
|
||||
(path): path is string => typeof path === "string",
|
||||
);
|
||||
const paths = (Array.isArray(selected) ? selected : [selected]).filter((path): path is string => typeof path === "string");
|
||||
await importJdbcDriverPaths(paths);
|
||||
}
|
||||
|
||||
|
|
@ -820,19 +759,11 @@ watch(driverStoreTab, (tab) => {
|
|||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-sm font-medium">{{ t("driverStore.usageTitle") }}</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{
|
||||
usageSummary.length
|
||||
? t("driverStore.usageTotal", { size: formatBytes(usageSummary[0].bytes) })
|
||||
: t("driverStore.calculating")
|
||||
}}
|
||||
{{ usageSummary.length ? t("driverStore.usageTotal", { size: formatBytes(usageSummary[0].bytes) }) : t("driverStore.calculating") }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="usageSummary.length" class="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-5">
|
||||
<div
|
||||
v-for="item in usageSummary"
|
||||
:key="item.key"
|
||||
class="rounded-lg border bg-background/50 px-2.5 py-2 text-center"
|
||||
>
|
||||
<div v-for="item in usageSummary" :key="item.key" class="rounded-lg border bg-background/50 px-2.5 py-2 text-center">
|
||||
<div class="text-[11px] text-muted-foreground">{{ item.label }}</div>
|
||||
<div class="mt-0.5 text-xs font-medium">{{ formatBytes(item.bytes) }}</div>
|
||||
</div>
|
||||
|
|
@ -842,12 +773,7 @@ watch(driverStoreTab, (tab) => {
|
|||
<div class="min-w-0 truncate text-xs text-muted-foreground">
|
||||
{{ t("driverStore.offlineDownloadHint") }}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 shrink-0 rounded-full text-xs gap-1 whitespace-nowrap"
|
||||
@click="openOfflineDriverDownload"
|
||||
>
|
||||
<Button variant="outline" size="sm" class="h-7 shrink-0 rounded-full text-xs gap-1 whitespace-nowrap" @click="openOfflineDriverDownload">
|
||||
<ExternalLink class="h-3.5 w-3.5" />
|
||||
{{ t("driverStore.offlineDownloadLink") }}
|
||||
</Button>
|
||||
|
|
@ -870,23 +796,11 @@ watch(driverStoreTab, (tab) => {
|
|||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div v-if="driverStoreTab !== 'runtime'" class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 rounded-full text-xs gap-1 text-muted-foreground"
|
||||
:disabled="importingZip"
|
||||
@click="importOfflineZip"
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="h-7 rounded-full text-xs gap-1 text-muted-foreground" :disabled="importingZip" @click="importOfflineZip">
|
||||
<FileUp class="h-3.5 w-3.5" />
|
||||
{{ importingZip ? t("driverStore.importing") : t("driverStore.importOfflinePackage") }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 rounded-full text-xs gap-1 text-muted-foreground"
|
||||
:disabled="refreshing"
|
||||
@click="forceRefresh"
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="h-7 rounded-full text-xs gap-1 text-muted-foreground" :disabled="refreshing" @click="forceRefresh">
|
||||
<RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': refreshing }" />
|
||||
{{ t("driverStore.refresh") }}
|
||||
</Button>
|
||||
|
|
@ -909,96 +823,40 @@ watch(driverStoreTab, (tab) => {
|
|||
<SelectItem value="custom">{{ t("driverStore.javaRuntimeCustom") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
v-if="javaRuntimeConfig.mode === 'custom'"
|
||||
v-model="customJavaPath"
|
||||
class="h-8 min-w-[180px] flex-1 text-xs"
|
||||
:placeholder="t('driverStore.customJavaPathPlaceholder')"
|
||||
@keydown.enter.prevent="saveJavaRuntimeConfig"
|
||||
/>
|
||||
<Input v-if="javaRuntimeConfig.mode === 'custom'" v-model="customJavaPath" class="h-8 min-w-[180px] flex-1 text-xs" :placeholder="t('driverStore.customJavaPathPlaceholder')" @keydown.enter.prevent="saveJavaRuntimeConfig" />
|
||||
<span v-else class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
|
||||
{{
|
||||
javaRuntimeConfig.mode === "system"
|
||||
? t("driverStore.systemJavaHint")
|
||||
: t("driverStore.jreRuntimeAutoDownloadHint")
|
||||
}}
|
||||
{{ javaRuntimeConfig.mode === "system" ? t("driverStore.systemJavaHint") : t("driverStore.jreRuntimeAutoDownloadHint") }}
|
||||
</span>
|
||||
<Button
|
||||
v-if="javaRuntimeConfig.mode === 'custom'"
|
||||
variant="outline"
|
||||
class="h-8 shrink-0 rounded-full text-xs"
|
||||
@click="chooseCustomJavaPath"
|
||||
>
|
||||
<Button v-if="javaRuntimeConfig.mode === 'custom'" variant="outline" class="h-8 shrink-0 rounded-full text-xs" @click="chooseCustomJavaPath">
|
||||
<FolderOpen class="h-3.5 w-3.5" />
|
||||
{{ t("driverStore.choose") }}
|
||||
</Button>
|
||||
<Button
|
||||
class="h-8 shrink-0 rounded-full text-xs"
|
||||
:disabled="savingJavaRuntime || (javaRuntimeConfig.mode === 'custom' && !customJavaPath.trim())"
|
||||
@click="saveJavaRuntimeConfig"
|
||||
>
|
||||
<Button class="h-8 shrink-0 rounded-full text-xs" :disabled="savingJavaRuntime || (javaRuntimeConfig.mode === 'custom' && !customJavaPath.trim())" @click="saveJavaRuntimeConfig">
|
||||
{{ savingJavaRuntime ? t("driverStore.saving") : t("settings.save") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="installedJres.length > 0" class="divide-y rounded-lg border bg-background/50">
|
||||
<div
|
||||
v-for="jre in installedJres"
|
||||
:key="jre.key"
|
||||
class="flex items-center justify-between gap-3 px-3 py-2.5"
|
||||
>
|
||||
<div v-for="jre in installedJres" :key="jre.key" class="flex items-center justify-between gap-3 px-3 py-2.5">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium">{{ t("driverStore.jreRuntimeTitle", { jre: jre.key }) }}</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-3">
|
||||
<span
|
||||
v-if="jreUsageLabel(jre.key)"
|
||||
class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground"
|
||||
>
|
||||
<span v-if="jreUsageLabel(jre.key)" class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
{{ jreUsageLabel(jre.key) }}
|
||||
</span>
|
||||
<Check v-if="jre.installed" class="h-4 w-4 text-green-600" />
|
||||
<span v-else class="text-xs text-muted-foreground">{{ t("driverStore.notInstalled") }}</span>
|
||||
<DriverInstallProgressCircle
|
||||
v-if="reinstallingJre === jre.key"
|
||||
:percent="progressNumber"
|
||||
:title="
|
||||
progressTitle(jre.installed ? t('driverStore.reinstalling') : t('driverStore.installing'))
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
v-else-if="!jre.installed"
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
class="h-8 rounded-full text-xs"
|
||||
:disabled="reinstallingJre !== null || installing !== null"
|
||||
@click="reinstallJre(jre.key)"
|
||||
>
|
||||
<DriverInstallProgressCircle v-if="reinstallingJre === jre.key" :percent="progressNumber" :title="progressTitle(jre.installed ? t('driverStore.reinstalling') : t('driverStore.installing'))" />
|
||||
<Button v-else-if="!jre.installed" type="button" variant="default" size="sm" class="h-8 rounded-full text-xs" :disabled="reinstallingJre !== null || installing !== null" @click="reinstallJre(jre.key)">
|
||||
<Download class="h-3.5 w-3.5 mr-1" />
|
||||
{{ t("driverStore.install") }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="jre.installed"
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 rounded-full text-xs"
|
||||
:disabled="reinstallingJre !== null || installing !== null"
|
||||
@click="reinstallJre(jre.key)"
|
||||
>
|
||||
<Button v-else-if="jre.installed" type="button" variant="outline" size="sm" class="h-8 rounded-full text-xs" :disabled="reinstallingJre !== null || installing !== null" @click="reinstallJre(jre.key)">
|
||||
<RotateCcw class="h-3.5 w-3.5 mr-1" />
|
||||
{{ t("driverStore.reinstall") }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="jre.installed"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 rounded-full text-xs text-muted-foreground hover:text-destructive"
|
||||
:disabled="reinstallingJre !== null || installing !== null"
|
||||
@click="uninstallJre(jre.key)"
|
||||
>
|
||||
<Button v-if="jre.installed" type="button" variant="ghost" size="sm" class="h-8 rounded-full text-xs text-muted-foreground hover:text-destructive" :disabled="reinstallingJre !== null || installing !== null" @click="uninstallJre(jre.key)">
|
||||
{{ t("driverStore.uninstall") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -1009,11 +867,7 @@ watch(driverStoreTab, (tab) => {
|
|||
<!-- Driver List -->
|
||||
<div class="relative">
|
||||
<Search class="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
v-model="agentDriverSearch"
|
||||
class="h-8 pl-8 text-xs"
|
||||
:placeholder="t('driverStore.searchDrivers')"
|
||||
/>
|
||||
<Input v-model="agentDriverSearch" class="h-8 pl-8 text-xs" :placeholder="t('driverStore.searchDrivers')" />
|
||||
</div>
|
||||
<div v-if="drivers.length === 0" class="py-12 text-center text-sm text-muted-foreground">
|
||||
{{ t("common.loading") }}
|
||||
|
|
@ -1023,29 +877,14 @@ watch(driverStoreTab, (tab) => {
|
|||
</div>
|
||||
<div v-else class="rounded-md border divide-y">
|
||||
<div v-if="updatableCount > 0" class="flex items-center justify-between px-4 py-2 bg-muted/30">
|
||||
<span class="text-xs text-muted-foreground">{{
|
||||
t("driverStore.driversUpdatable", { count: updatableCount })
|
||||
}}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
class="h-7 rounded-full text-xs"
|
||||
:disabled="installing !== null || upgradingAll"
|
||||
@click="upgradeAll"
|
||||
>
|
||||
<span class="text-xs text-muted-foreground">{{ t("driverStore.driversUpdatable", { count: updatableCount }) }}</span>
|
||||
<Button size="sm" class="h-7 rounded-full text-xs" :disabled="installing !== null || upgradingAll" @click="upgradeAll">
|
||||
<Loader2 v-if="upgradingAll" class="h-3 w-3 animate-spin mr-1" />
|
||||
<Download v-else class="h-3 w-3 mr-1" />
|
||||
{{
|
||||
upgradingAll
|
||||
? t("driverStore.upgradingProgress", { current: upgradingIndex, total: upgradingTotal })
|
||||
: t("driverStore.upgradeAll")
|
||||
}}
|
||||
{{ upgradingAll ? t("driverStore.upgradingProgress", { current: upgradingIndex, total: upgradingTotal }) : t("driverStore.upgradeAll") }}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-for="driver in filteredAgentDrivers"
|
||||
:key="driver.db_type"
|
||||
class="flex items-center gap-3 px-4 py-2.5 transition hover:bg-muted/30"
|
||||
>
|
||||
<div v-for="driver in filteredAgentDrivers" :key="driver.db_type" class="flex items-center gap-3 px-4 py-2.5 transition hover:bg-muted/30">
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-muted/60 shrink-0">
|
||||
<DatabaseIcon :db-type="driver.db_type" class="h-5 w-5" />
|
||||
</span>
|
||||
|
|
@ -1053,66 +892,28 @@ watch(driverStoreTab, (tab) => {
|
|||
<div class="text-sm font-medium">{{ driver.label }}</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1.5">
|
||||
<span
|
||||
v-if="driver.jre"
|
||||
class="rounded-full px-2 py-0.5 text-[11px]"
|
||||
:class="driver.jre !== '21' ? 'bg-blue-500/10 text-blue-600' : 'bg-muted text-muted-foreground'"
|
||||
>JRE {{ driver.jre }}</span
|
||||
>
|
||||
<span v-if="driver.jre" class="rounded-full px-2 py-0.5 text-[11px]" :class="driver.jre !== '21' ? 'bg-blue-500/10 text-blue-600' : 'bg-muted text-muted-foreground'">JRE {{ driver.jre }}</span>
|
||||
<template v-if="driver.installed">
|
||||
<span class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground"
|
||||
>v{{ driver.installed_version }}</span
|
||||
>
|
||||
<span
|
||||
v-if="driver.update_available"
|
||||
class="rounded-full bg-amber-500/15 px-2 py-0.5 text-[11px] text-amber-600"
|
||||
>→ v{{ driver.version }}</span
|
||||
>
|
||||
<span class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">v{{ driver.installed_version }}</span>
|
||||
<span v-if="driver.update_available" class="rounded-full bg-amber-500/15 px-2 py-0.5 text-[11px] text-amber-600">→ v{{ driver.version }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span
|
||||
v-if="driver.version"
|
||||
class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground"
|
||||
>v{{ driver.version }}</span
|
||||
>
|
||||
<span v-if="driver.version" class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">v{{ driver.version }}</span>
|
||||
</template>
|
||||
<span
|
||||
v-if="formatSize(driver.size)"
|
||||
class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground"
|
||||
>{{ formatSize(driver.size) }}</span
|
||||
>
|
||||
<span v-if="formatSize(driver.size)" class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">{{ formatSize(driver.size) }}</span>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
v-if="!driver.installed && isDriverQueued(driver.db_type)"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-7 rounded-full border-green-500/30 bg-green-500/10 text-xs text-green-700 hover:bg-green-500/15"
|
||||
:disabled="upgradingAll"
|
||||
@click="removeQueuedDriverInstall(driver.db_type)"
|
||||
>
|
||||
<Button v-if="!driver.installed && isDriverQueued(driver.db_type)" size="sm" variant="outline" class="h-7 rounded-full border-green-500/30 bg-green-500/10 text-xs text-green-700 hover:bg-green-500/15" :disabled="upgradingAll" @click="removeQueuedDriverInstall(driver.db_type)">
|
||||
<Clock3 class="h-3 w-3 mr-1" />
|
||||
{{ t("driverStore.queued") }}
|
||||
</Button>
|
||||
<DriverInstallProgressCircle
|
||||
v-else-if="!driver.installed && isDriverProgressActive(driver.db_type)"
|
||||
:percent="progressNumber"
|
||||
:title="progressTitle(t('driverStore.installing'))"
|
||||
/>
|
||||
<Button
|
||||
v-else-if="!driver.installed"
|
||||
size="sm"
|
||||
class="h-7 rounded-full text-xs"
|
||||
:disabled="upgradingAll"
|
||||
@click="installDriver(driver.db_type)"
|
||||
>
|
||||
<DriverInstallProgressCircle v-else-if="!driver.installed && isDriverProgressActive(driver.db_type)" :percent="progressNumber" :title="progressTitle(t('driverStore.installing'))" />
|
||||
<Button v-else-if="!driver.installed" size="sm" class="h-7 rounded-full text-xs" :disabled="upgradingAll" @click="installDriver(driver.db_type)">
|
||||
<Download class="h-3 w-3 mr-1" />
|
||||
{{ t("driverStore.install") }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="
|
||||
!driver.installed && !isDriverProgressActive(driver.db_type) && !isDriverQueued(driver.db_type)
|
||||
"
|
||||
v-if="!driver.installed && !isDriverProgressActive(driver.db_type) && !isDriverQueued(driver.db_type)"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="h-7 w-7 rounded-full text-xs text-muted-foreground"
|
||||
|
|
@ -1123,10 +924,7 @@ watch(driverStoreTab, (tab) => {
|
|||
<FileUp class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<template v-else>
|
||||
<Check
|
||||
v-if="!(driver.update_available && isDriverProgressActive(driver.db_type))"
|
||||
class="h-4 w-4 text-green-600"
|
||||
/>
|
||||
<Check v-if="!(driver.update_available && isDriverProgressActive(driver.db_type))" class="h-4 w-4 text-green-600" />
|
||||
<Button
|
||||
v-if="driver.update_available && isDriverQueued(driver.db_type)"
|
||||
size="sm"
|
||||
|
|
@ -1138,28 +936,11 @@ watch(driverStoreTab, (tab) => {
|
|||
<Clock3 class="h-3 w-3 mr-1" />
|
||||
{{ t("driverStore.queued") }}
|
||||
</Button>
|
||||
<DriverInstallProgressCircle
|
||||
v-else-if="driver.update_available && isDriverProgressActive(driver.db_type)"
|
||||
:percent="progressNumber"
|
||||
:title="progressTitle(t('driverStore.updating'))"
|
||||
/>
|
||||
<Button
|
||||
v-else-if="driver.update_available"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-7 rounded-full text-xs"
|
||||
:disabled="upgradingAll"
|
||||
@click="installDriver(driver.db_type)"
|
||||
>
|
||||
<DriverInstallProgressCircle v-else-if="driver.update_available && isDriverProgressActive(driver.db_type)" :percent="progressNumber" :title="progressTitle(t('driverStore.updating'))" />
|
||||
<Button v-else-if="driver.update_available" size="sm" variant="outline" class="h-7 rounded-full text-xs" :disabled="upgradingAll" @click="installDriver(driver.db_type)">
|
||||
{{ t("driverStore.update") }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 rounded-full text-xs text-muted-foreground hover:text-destructive"
|
||||
:disabled="installing !== null || upgradingAll || isDriverQueued(driver.db_type)"
|
||||
@click="uninstallDriver(driver.db_type)"
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="h-7 rounded-full text-xs text-muted-foreground hover:text-destructive" :disabled="installing !== null || upgradingAll || isDriverQueued(driver.db_type)" @click="uninstallDriver(driver.db_type)">
|
||||
{{ t("driverStore.uninstall") }}
|
||||
</Button>
|
||||
</template>
|
||||
|
|
@ -1180,11 +961,7 @@ watch(driverStoreTab, (tab) => {
|
|||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-3">
|
||||
<span
|
||||
v-if="jdbcPluginStatus?.installed"
|
||||
class="text-xs"
|
||||
:class="jdbcPluginStatus.compatible ? 'text-green-600' : 'text-destructive'"
|
||||
>
|
||||
<span v-if="jdbcPluginStatus?.installed" class="text-xs" :class="jdbcPluginStatus.compatible ? 'text-green-600' : 'text-destructive'">
|
||||
{{
|
||||
jdbcPluginStatus.compatible
|
||||
? t("settings.jdbcPluginInstalled", {
|
||||
|
|
@ -1193,49 +970,17 @@ watch(driverStoreTab, (tab) => {
|
|||
: t("settings.jdbcPluginIncompatible")
|
||||
}}
|
||||
</span>
|
||||
<span
|
||||
v-if="jdbcPluginStatus?.installed && jdbcPluginStatus.update_available"
|
||||
class="rounded-full bg-amber-500/15 px-2 py-0.5 text-[11px] text-amber-600"
|
||||
>→ v{{ jdbcPluginStatus.latest_version }}</span
|
||||
>
|
||||
<Button
|
||||
v-if="jdbcPluginStatus?.installed && jdbcPluginStatus.update_available"
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="rounded-full"
|
||||
:disabled="isInstallingJdbcPlugin"
|
||||
@click="installJdbcPlugin"
|
||||
>
|
||||
<span v-if="jdbcPluginStatus?.installed && jdbcPluginStatus.update_available" class="rounded-full bg-amber-500/15 px-2 py-0.5 text-[11px] text-amber-600">→ v{{ jdbcPluginStatus.latest_version }}</span>
|
||||
<Button v-if="jdbcPluginStatus?.installed && jdbcPluginStatus.update_available" type="button" variant="outline" class="rounded-full" :disabled="isInstallingJdbcPlugin" @click="installJdbcPlugin">
|
||||
{{ isInstallingJdbcPlugin ? t("common.loading") : t("settings.jdbcPluginUpdate") }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="jdbcPluginStatus?.installed"
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="rounded-full"
|
||||
:disabled="isUninstallingJdbcPlugin"
|
||||
@click="uninstallJdbcPlugin"
|
||||
>
|
||||
<Button v-if="jdbcPluginStatus?.installed" type="button" variant="outline" class="rounded-full" :disabled="isUninstallingJdbcPlugin" @click="uninstallJdbcPlugin">
|
||||
{{ isUninstallingJdbcPlugin ? t("common.loading") : t("settings.jdbcPluginUninstall") }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
type="button"
|
||||
variant="default"
|
||||
class="rounded-full"
|
||||
:disabled="isInstallingJdbcPlugin"
|
||||
@click="installJdbcPlugin"
|
||||
>
|
||||
<Button v-else type="button" variant="default" class="rounded-full" :disabled="isInstallingJdbcPlugin" @click="installJdbcPlugin">
|
||||
{{ isInstallingJdbcPlugin ? t("common.loading") : t("settings.jdbcPluginInstall") }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!jdbcPluginStatus?.installed"
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="rounded-full"
|
||||
:disabled="isInstallingJdbcPlugin"
|
||||
@click="installJdbcPluginLocal"
|
||||
>
|
||||
<Button v-if="!jdbcPluginStatus?.installed" type="button" variant="outline" class="rounded-full" :disabled="isInstallingJdbcPlugin" @click="installJdbcPluginLocal">
|
||||
<FolderOpen class="h-3.5 w-3.5 mr-1" />
|
||||
{{ t("driverStore.localInstall") }}
|
||||
</Button>
|
||||
|
|
@ -1250,25 +995,11 @@ watch(driverStoreTab, (tab) => {
|
|||
</div>
|
||||
<div class="relative">
|
||||
<Search class="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
v-model="jdbcDriverSearch"
|
||||
class="h-8 pl-8 text-xs"
|
||||
:placeholder="t('driverStore.searchJdbcDrivers')"
|
||||
/>
|
||||
<Input v-model="jdbcDriverSearch" class="h-8 pl-8 text-xs" :placeholder="t('driverStore.searchJdbcDrivers')" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
v-model="jdbcDriverPathInput"
|
||||
class="flex-1"
|
||||
:placeholder="t('settings.jdbcDriverPathPlaceholder')"
|
||||
@keydown.enter.prevent="importJdbcDriverPathInput"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="rounded-full"
|
||||
:disabled="!jdbcDriverPathInput.trim()"
|
||||
@click="importJdbcDriverPathInput"
|
||||
>
|
||||
<Input v-model="jdbcDriverPathInput" class="flex-1" :placeholder="t('settings.jdbcDriverPathPlaceholder')" @keydown.enter.prevent="importJdbcDriverPathInput" />
|
||||
<Button variant="outline" class="rounded-full" :disabled="!jdbcDriverPathInput.trim()" @click="importJdbcDriverPathInput">
|
||||
{{ t("settings.jdbcImportPath") }}
|
||||
</Button>
|
||||
<Button class="shrink-0 rounded-full" @click="importJdbcDrivers">
|
||||
|
|
@ -1295,12 +1026,7 @@ watch(driverStoreTab, (tab) => {
|
|||
<div class="truncate text-xs text-muted-foreground">{{ driver.path }}</div>
|
||||
</div>
|
||||
<div class="shrink-0 text-xs text-muted-foreground">{{ formatBytes(driver.size) }}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 shrink-0 rounded-full"
|
||||
@click="deleteJdbcDriver(driver.path)"
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8 shrink-0 rounded-full" @click="deleteJdbcDriver(driver.path)">
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -1326,14 +1052,7 @@ watch(driverStoreTab, (tab) => {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 shrink-0 rounded-full text-muted-foreground"
|
||||
:title="t('driverStore.refresh')"
|
||||
:disabled="runtimeLoading"
|
||||
@click="refreshDriverRuntime"
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8 shrink-0 rounded-full text-muted-foreground" :title="t('driverStore.refresh')" :disabled="runtimeLoading" @click="refreshDriverRuntime">
|
||||
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': runtimeLoading }" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -1342,9 +1061,7 @@ watch(driverStoreTab, (tab) => {
|
|||
<div class="text-xs font-medium text-amber-700 dark:text-amber-300">
|
||||
{{ t("driverStore.runtimeLastError") }}
|
||||
</div>
|
||||
<pre class="mt-1 max-h-20 overflow-auto whitespace-pre-wrap text-[11px] text-muted-foreground">{{
|
||||
runtimeSummary.last_error
|
||||
}}</pre>
|
||||
<pre class="mt-1 max-h-20 overflow-auto whitespace-pre-wrap text-[11px] text-muted-foreground">{{ runtimeSummary.last_error }}</pre>
|
||||
</div>
|
||||
|
||||
<div v-if="runtimeLoading && !runtimeSummary" class="p-6 text-center text-sm text-muted-foreground">
|
||||
|
|
@ -1357,9 +1074,7 @@ watch(driverStoreTab, (tab) => {
|
|||
{{ t("driverStore.runtimeEmpty") }}
|
||||
</div>
|
||||
<div v-else>
|
||||
<div
|
||||
class="hidden grid-cols-[minmax(0,1.6fr)_72px_56px_76px_58px_76px_72px] gap-2 border-b bg-muted/30 px-4 py-2 text-[11px] font-medium text-muted-foreground lg:grid"
|
||||
>
|
||||
<div class="hidden grid-cols-[minmax(0,1.6fr)_72px_56px_76px_58px_76px_72px] gap-2 border-b bg-muted/30 px-4 py-2 text-[11px] font-medium text-muted-foreground lg:grid">
|
||||
<div>{{ t("driverStore.runtimeDrivers") }}</div>
|
||||
<div>{{ t("driverStore.runtimeHealth") }}</div>
|
||||
<div>{{ t("driverStore.runtimePid") }}</div>
|
||||
|
|
@ -1369,21 +1084,12 @@ watch(driverStoreTab, (tab) => {
|
|||
<div class="text-right">{{ t("driverStore.runtimeActions") }}</div>
|
||||
</div>
|
||||
<div class="divide-y">
|
||||
<div
|
||||
v-for="runtime in runtimeSummary.runtimes"
|
||||
:key="runtime.id"
|
||||
class="grid gap-2 px-4 py-3 transition hover:bg-muted/25 lg:grid-cols-[minmax(0,1.6fr)_72px_56px_76px_58px_76px_72px] lg:items-center"
|
||||
>
|
||||
<div v-for="runtime in runtimeSummary.runtimes" :key="runtime.id" class="grid gap-2 px-4 py-3 transition hover:bg-muted/25 lg:grid-cols-[minmax(0,1.6fr)_72px_56px_76px_58px_76px_72px] lg:items-center">
|
||||
<div class="min-w-0">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="h-2 w-2 shrink-0 rounded-full" :class="runtimeStatusDotClass(runtime.status)" />
|
||||
<span class="truncate text-sm font-medium">{{ runtime.label }}</span>
|
||||
<span
|
||||
v-if="runtime.version"
|
||||
class="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
v{{ runtime.version }}
|
||||
</span>
|
||||
<span v-if="runtime.version" class="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"> v{{ runtime.version }} </span>
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span>{{ runtimeKindLabel(runtime) }}</span>
|
||||
|
|
@ -1393,9 +1099,7 @@ watch(driverStoreTab, (tab) => {
|
|||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 lg:block">
|
||||
<span class="lg:hidden text-[11px] text-muted-foreground">{{
|
||||
t("driverStore.runtimeHealth")
|
||||
}}</span>
|
||||
<span class="lg:hidden text-[11px] text-muted-foreground">{{ t("driverStore.runtimeHealth") }}</span>
|
||||
<span class="rounded-full px-2 py-0.5 text-[11px]" :class="runtimeStatusClass(runtime.status)">
|
||||
{{ runtimeStatusLabel(runtime.status) }}
|
||||
</span>
|
||||
|
|
@ -1416,41 +1120,19 @@ watch(driverStoreTab, (tab) => {
|
|||
{{ formatRuntimeUptime(runtime.uptime_seconds) }}
|
||||
</div>
|
||||
<div class="flex min-w-0 items-center gap-1.5 lg:justify-end">
|
||||
<Button
|
||||
v-if="runtime.can_stop"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 rounded-full text-muted-foreground hover:text-destructive"
|
||||
:title="t('driverStore.runtimeStop')"
|
||||
:disabled="runtimeBusy === runtime.id"
|
||||
@click="stopRuntime(runtime)"
|
||||
>
|
||||
<Button v-if="runtime.can_stop" variant="ghost" size="icon" class="h-7 w-7 rounded-full text-muted-foreground hover:text-destructive" :title="t('driverStore.runtimeStop')" :disabled="runtimeBusy === runtime.id" @click="stopRuntime(runtime)">
|
||||
<Square class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="runtime.can_restart"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 rounded-full text-muted-foreground"
|
||||
:title="t('driverStore.runtimeRestart')"
|
||||
:disabled="runtimeBusy === runtime.id"
|
||||
@click="restartRuntime(runtime)"
|
||||
>
|
||||
<Button v-if="runtime.can_restart" variant="ghost" size="icon" class="h-7 w-7 rounded-full text-muted-foreground" :title="t('driverStore.runtimeRestart')" :disabled="runtimeBusy === runtime.id" @click="restartRuntime(runtime)">
|
||||
<RotateCcw class="h-3.5 w-3.5" :class="{ 'animate-spin': runtimeBusy === runtime.id }" />
|
||||
</Button>
|
||||
<span
|
||||
v-if="!runtime.can_stop && !runtime.can_restart"
|
||||
class="min-w-0 truncate text-[11px] text-muted-foreground lg:text-right"
|
||||
:title="runtimeControlUnavailableReasonLabel(runtime.control_unavailable_reason)"
|
||||
>
|
||||
<span v-if="!runtime.can_stop && !runtime.can_restart" class="min-w-0 truncate text-[11px] text-muted-foreground lg:text-right" :title="runtimeControlUnavailableReasonLabel(runtime.control_unavailable_reason)">
|
||||
{{ runtimeControlUnavailableReasonLabel(runtime.control_unavailable_reason) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="runtime.last_error" class="rounded-md bg-muted/60 p-2 lg:col-span-7">
|
||||
<pre class="max-h-16 overflow-auto whitespace-pre-wrap text-[11px] text-muted-foreground">{{
|
||||
runtime.last_error
|
||||
}}</pre>
|
||||
<pre class="max-h-16 overflow-auto whitespace-pre-wrap text-[11px] text-muted-foreground">{{ runtime.last_error }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -29,13 +29,7 @@ function clearError() {
|
|||
<template>
|
||||
<Popover v-if="errorMessage">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded text-amber-500 hover:bg-amber-500/10 hover:text-amber-600 focus:outline-none focus:ring-1 focus:ring-amber-500/40"
|
||||
:class="triggerClass"
|
||||
:title="t('connection.lastError')"
|
||||
@click.stop
|
||||
>
|
||||
<button type="button" class="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded text-amber-500 hover:bg-amber-500/10 hover:text-amber-600 focus:outline-none focus:ring-1 focus:ring-amber-500/40" :class="triggerClass" :title="t('connection.lastError')" @click.stop>
|
||||
<AlertTriangle class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
|
|
@ -49,12 +43,7 @@ function clearError() {
|
|||
{{ errorMessage }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
:title="t('connection.clearError')"
|
||||
@click="clearError"
|
||||
>
|
||||
<button type="button" class="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground" :title="t('connection.clearError')" @click="clearError">
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,30 +11,11 @@ import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
|||
import * as api from "@/lib/api";
|
||||
import { DIAGRAM_SQL_TYPES, isSchemaAware as isSchemaAwareDatabase } from "@/lib/databaseCapabilities";
|
||||
import { databaseOptionsForConnection } from "@/composables/useDatabaseOptions";
|
||||
import {
|
||||
buildDiagramRelationships,
|
||||
filterDiagramTables,
|
||||
layoutDiagramTables,
|
||||
type DiagramPosition,
|
||||
type DiagramRelationship,
|
||||
type DiagramTable,
|
||||
} from "@/lib/erDiagram";
|
||||
import { buildDiagramRelationships, filterDiagramTables, layoutDiagramTables, type DiagramPosition, type DiagramRelationship, type DiagramTable } from "@/lib/erDiagram";
|
||||
import { buildEngineeringDiagram } from "@/lib/engineeringDiagram";
|
||||
import { buildEngineeringDiagramSvg, buildTableDiagramSvg, diagramSvgFileName } from "@/lib/diagramSvgExport";
|
||||
import { clampDiagramZoom, zoomFromGestureScale, zoomFromWheelDelta } from "@/lib/diagramZoom";
|
||||
import {
|
||||
Download,
|
||||
KeyRound,
|
||||
Link2,
|
||||
Loader2,
|
||||
Maximize2,
|
||||
Network,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Table2,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from "@lucide/vue";
|
||||
import { Download, KeyRound, Link2, Loader2, Maximize2, Network, RefreshCw, Search, Table2, ZoomIn, ZoomOut } from "@lucide/vue";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
|
||||
|
|
@ -86,9 +67,7 @@ const dragging = ref<{
|
|||
originY: number;
|
||||
} | null>(null);
|
||||
|
||||
const sqlConnections = computed(() =>
|
||||
store.connections.filter((connection) => DIAGRAM_SQL_TYPES.has(connection.db_type)),
|
||||
);
|
||||
const sqlConnections = computed(() => store.connections.filter((connection) => DIAGRAM_SQL_TYPES.has(connection.db_type)));
|
||||
|
||||
const selectedConnection = computed(() => (connectionId.value ? store.getConfig(connectionId.value) : undefined));
|
||||
|
||||
|
|
@ -120,15 +99,9 @@ const visibleTableMap = computed(() => new Map(visibleTables.value.map((table) =
|
|||
|
||||
const visibleRelationships = computed(() => buildDiagramRelationships(visibleTables.value));
|
||||
|
||||
const diagramReady = computed(
|
||||
() => !!connectionId.value && !!database.value && (!isSchemaAware.value || !!schema.value),
|
||||
);
|
||||
const diagramReady = computed(() => !!connectionId.value && !!database.value && (!isSchemaAware.value || !!schema.value));
|
||||
|
||||
const loadingText = computed(() =>
|
||||
totalTableCount.value > 0
|
||||
? t("diagram.loadingProgress", { loaded: loadedTableCount.value, total: totalTableCount.value })
|
||||
: t("diagram.loading"),
|
||||
);
|
||||
const loadingText = computed(() => (totalTableCount.value > 0 ? t("diagram.loadingProgress", { loaded: loadedTableCount.value, total: totalTableCount.value }) : t("diagram.loading")));
|
||||
|
||||
function connectionIconType(id: string) {
|
||||
const config = store.getConfig(id);
|
||||
|
|
@ -153,13 +126,9 @@ const canvasSize = computed(() => {
|
|||
return { width, height };
|
||||
});
|
||||
|
||||
const engineeringDiagram = computed(() =>
|
||||
buildEngineeringDiagram(visibleTables.value, visibleRelationships.value, positions.value),
|
||||
);
|
||||
const engineeringDiagram = computed(() => buildEngineeringDiagram(visibleTables.value, visibleRelationships.value, positions.value));
|
||||
|
||||
const activeCanvasSize = computed(() =>
|
||||
diagramMode.value === "engineering" ? engineeringDiagram.value.canvas : canvasSize.value,
|
||||
);
|
||||
const activeCanvasSize = computed(() => (diagramMode.value === "engineering" ? engineeringDiagram.value.canvas : canvasSize.value));
|
||||
|
||||
function resetLayout() {
|
||||
const count = visibleTables.value.length;
|
||||
|
|
@ -237,33 +206,19 @@ function routeSideX(rect: TableRect, routeX: number, offset = 0): number {
|
|||
}
|
||||
|
||||
function tableRects(): TableRect[] {
|
||||
return visibleTables.value
|
||||
.map((table) => getTableRect(table.name))
|
||||
.filter((rect): rect is TableRect => rect !== null);
|
||||
return visibleTables.value.map((table) => getTableRect(table.name)).filter((rect): rect is TableRect => rect !== null);
|
||||
}
|
||||
|
||||
function isVerticalRouteBlocked(routeX: number, y1: number, y2: number, ignoredTables: Set<string>): boolean {
|
||||
const top = Math.min(y1, y2);
|
||||
const bottom = Math.max(y1, y2);
|
||||
return tableRects().some(
|
||||
(rect) =>
|
||||
!ignoredTables.has(rect.name) &&
|
||||
routeX >= rect.x - ROUTE_BLOCK_MARGIN &&
|
||||
routeX <= rect.x + rect.width + ROUTE_BLOCK_MARGIN &&
|
||||
rangesOverlap(top, bottom, rect.y - ROUTE_BLOCK_MARGIN, rect.y + rect.height + ROUTE_BLOCK_MARGIN),
|
||||
);
|
||||
return tableRects().some((rect) => !ignoredTables.has(rect.name) && routeX >= rect.x - ROUTE_BLOCK_MARGIN && routeX <= rect.x + rect.width + ROUTE_BLOCK_MARGIN && rangesOverlap(top, bottom, rect.y - ROUTE_BLOCK_MARGIN, rect.y + rect.height + ROUTE_BLOCK_MARGIN));
|
||||
}
|
||||
|
||||
function isHorizontalRouteBlocked(y: number, x1: number, x2: number, ignoredTables: Set<string>): boolean {
|
||||
const left = Math.min(x1, x2);
|
||||
const right = Math.max(x1, x2);
|
||||
return tableRects().some(
|
||||
(rect) =>
|
||||
!ignoredTables.has(rect.name) &&
|
||||
y >= rect.y - ROUTE_BLOCK_MARGIN &&
|
||||
y <= rect.y + rect.height + ROUTE_BLOCK_MARGIN &&
|
||||
rangesOverlap(left, right, rect.x - ROUTE_BLOCK_MARGIN, rect.x + rect.width + ROUTE_BLOCK_MARGIN),
|
||||
);
|
||||
return tableRects().some((rect) => !ignoredTables.has(rect.name) && y >= rect.y - ROUTE_BLOCK_MARGIN && y <= rect.y + rect.height + ROUTE_BLOCK_MARGIN && rangesOverlap(left, right, rect.x - ROUTE_BLOCK_MARGIN, rect.x + rect.width + ROUTE_BLOCK_MARGIN));
|
||||
}
|
||||
|
||||
function candidateRouteXs(source: TableRect, target: TableRect): number[] {
|
||||
|
|
@ -297,11 +252,7 @@ function candidateRouteXs(source: TableRect, target: TableRect): number[] {
|
|||
const leftTargetX = routeSideX(target, left);
|
||||
const rightSourceX = routeSideX(source, right);
|
||||
const rightTargetX = routeSideX(target, right);
|
||||
return (
|
||||
Math.abs(left - leftSourceX) +
|
||||
Math.abs(left - leftTargetX) -
|
||||
(Math.abs(right - rightSourceX) + Math.abs(right - rightTargetX))
|
||||
);
|
||||
return Math.abs(left - leftSourceX) + Math.abs(left - leftTargetX) - (Math.abs(right - rightSourceX) + Math.abs(right - rightTargetX));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -319,11 +270,7 @@ function relationshipPath(relationship: DiagramRelationship): string {
|
|||
candidates.find((candidate) => {
|
||||
const x1 = routeSideX(source, candidate);
|
||||
const x2 = routeSideX(target, candidate);
|
||||
return (
|
||||
!isVerticalRouteBlocked(candidate, y1, y2, ignoredTables) &&
|
||||
!isHorizontalRouteBlocked(y1, x1, candidate, ignoredTables) &&
|
||||
!isHorizontalRouteBlocked(y2, candidate, x2, ignoredTables)
|
||||
);
|
||||
return !isVerticalRouteBlocked(candidate, y1, y2, ignoredTables) && !isHorizontalRouteBlocked(y1, x1, candidate, ignoredTables) && !isHorizontalRouteBlocked(y2, candidate, x2, ignoredTables);
|
||||
}) ??
|
||||
candidates[0] ??
|
||||
Math.max(source.x + source.width, target.x + target.width) + ROUTE_PADDING;
|
||||
|
|
@ -338,21 +285,11 @@ function engineeringEntityCenter(tableName: string): DiagramPosition {
|
|||
return entity ? { x: entity.x + entity.width / 2, y: entity.y + entity.height / 2 } : { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
function engineeringAttributeCenter(attribute: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}): DiagramPosition {
|
||||
function engineeringAttributeCenter(attribute: { x: number; y: number; width: number; height: number }): DiagramPosition {
|
||||
return { x: attribute.x + attribute.width / 2, y: attribute.y + attribute.height / 2 };
|
||||
}
|
||||
|
||||
function engineeringRelationshipCenter(relationship: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}): DiagramPosition {
|
||||
function engineeringRelationshipCenter(relationship: { x: number; y: number; width: number; height: number }): DiagramPosition {
|
||||
return {
|
||||
x: relationship.x + relationship.width / 2,
|
||||
y: relationship.y + relationship.height / 2,
|
||||
|
|
@ -397,12 +334,7 @@ async function loadSchemas() {
|
|||
try {
|
||||
const names = await api.listSchemas(connectionId.value, database.value);
|
||||
schemas.value = names;
|
||||
schema.value =
|
||||
props.prefillSchema && names.includes(props.prefillSchema)
|
||||
? props.prefillSchema
|
||||
: names.includes("public")
|
||||
? "public"
|
||||
: (names[0] ?? "");
|
||||
schema.value = props.prefillSchema && names.includes(props.prefillSchema) ? props.prefillSchema : names.includes("public") ? "public" : (names[0] ?? "");
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
} finally {
|
||||
|
|
@ -439,10 +371,7 @@ async function setSchema(value: string) {
|
|||
|
||||
async function loadTableDiagramData(tableName: string, querySchema: string): Promise<DiagramTable> {
|
||||
try {
|
||||
const [columns, foreignKeys] = await Promise.all([
|
||||
api.getColumns(connectionId.value, database.value, querySchema, tableName),
|
||||
api.listForeignKeys(connectionId.value, database.value, querySchema, tableName).catch(() => []),
|
||||
]);
|
||||
const [columns, foreignKeys] = await Promise.all([api.getColumns(connectionId.value, database.value, querySchema, tableName), api.listForeignKeys(connectionId.value, database.value, querySchema, tableName).catch(() => [])]);
|
||||
return { name: tableName, columns, foreignKeys };
|
||||
} catch (e) {
|
||||
failedTableCount.value += 1;
|
||||
|
|
@ -464,9 +393,7 @@ async function loadDiagram() {
|
|||
await store.ensureConnected(connectionId.value);
|
||||
const querySchema = schema.value || database.value;
|
||||
const tableInfos = await api.listTables(connectionId.value, database.value, querySchema);
|
||||
const baseTables = tableInfos
|
||||
.filter((table) => table.table_type !== "VIEW")
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const baseTables = tableInfos.filter((table) => table.table_type !== "VIEW").sort((a, b) => a.name.localeCompare(b.name));
|
||||
totalTableCount.value = baseTables.length;
|
||||
|
||||
const loadedTables: DiagramTable[] = [];
|
||||
|
|
@ -510,10 +437,7 @@ async function initialize() {
|
|||
if (props.prefillConnectionId) {
|
||||
connectionId.value = props.prefillConnectionId;
|
||||
await loadDatabases(props.prefillConnectionId);
|
||||
const initialDatabase =
|
||||
props.prefillDatabase && databases.value.includes(props.prefillDatabase)
|
||||
? props.prefillDatabase
|
||||
: props.prefillDatabase || databases.value[0] || "";
|
||||
const initialDatabase = props.prefillDatabase && databases.value.includes(props.prefillDatabase) ? props.prefillDatabase : props.prefillDatabase || databases.value[0] || "";
|
||||
if (initialDatabase) await setDatabase(initialDatabase);
|
||||
return;
|
||||
}
|
||||
|
|
@ -558,9 +482,7 @@ function resetZoomAndLayout() {
|
|||
}
|
||||
|
||||
function tableRelationshipPaths(): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
visibleRelationships.value.map((relationship) => [relationship.id, relationshipPath(relationship)]),
|
||||
);
|
||||
return Object.fromEntries(visibleRelationships.value.map((relationship) => [relationship.id, relationshipPath(relationship)]));
|
||||
}
|
||||
|
||||
function currentDiagramSvg(): string {
|
||||
|
|
@ -590,10 +512,7 @@ async function exportSvg() {
|
|||
const svgContent = currentDiagramSvg();
|
||||
|
||||
if (isTauriRuntime()) {
|
||||
const [{ save }, { writeTextFile }] = await Promise.all([
|
||||
import("@tauri-apps/plugin-dialog"),
|
||||
import("@tauri-apps/plugin-fs"),
|
||||
]);
|
||||
const [{ save }, { writeTextFile }] = await Promise.all([import("@tauri-apps/plugin-dialog"), import("@tauri-apps/plugin-fs")]);
|
||||
const path = await save({
|
||||
defaultPath,
|
||||
filters: [{ name: "SVG", extensions: ["svg"] }],
|
||||
|
|
@ -685,9 +604,7 @@ onUnmounted(stopDrag);
|
|||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent
|
||||
class="w-[94vw] max-w-[94vw] sm:max-w-[94vw] md:max-w-[94vw] lg:max-w-[94vw] xl:max-w-[94vw] h-[86vh] max-h-[86vh] gap-0 p-0 overflow-hidden flex flex-col"
|
||||
>
|
||||
<DialogContent class="w-[94vw] max-w-[94vw] sm:max-w-[94vw] md:max-w-[94vw] lg:max-w-[94vw] xl:max-w-[94vw] h-[86vh] max-h-[86vh] gap-0 p-0 overflow-hidden flex flex-col">
|
||||
<DialogHeader class="px-4 py-3 border-b">
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<Network class="w-4 h-4" />
|
||||
|
|
@ -714,11 +631,7 @@ onUnmounted(stopDrag);
|
|||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
:model-value="database"
|
||||
:disabled="!databases.length || loadingDatabases"
|
||||
@update:model-value="(value: any) => setDatabase(String(value))"
|
||||
>
|
||||
<Select :model-value="database" :disabled="!databases.length || loadingDatabases" @update:model-value="(value: any) => setDatabase(String(value))">
|
||||
<SelectTrigger class="h-8 w-44 text-xs">
|
||||
<SelectValue :placeholder="loadingDatabases ? t('common.loading') : t('diagram.selectDatabase')" />
|
||||
</SelectTrigger>
|
||||
|
|
@ -727,12 +640,7 @@ onUnmounted(stopDrag);
|
|||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
v-if="isSchemaAware"
|
||||
:model-value="schema"
|
||||
:disabled="!schemas.length || loadingSchemas"
|
||||
@update:model-value="(value: any) => setSchema(String(value))"
|
||||
>
|
||||
<Select v-if="isSchemaAware" :model-value="schema" :disabled="!schemas.length || loadingSchemas" @update:model-value="(value: any) => setSchema(String(value))">
|
||||
<SelectTrigger class="h-8 w-40 text-xs">
|
||||
<SelectValue :placeholder="loadingSchemas ? t('common.loading') : t('diagram.selectSchema')" />
|
||||
</SelectTrigger>
|
||||
|
|
@ -747,35 +655,17 @@ onUnmounted(stopDrag);
|
|||
</div>
|
||||
|
||||
<div class="flex h-8 shrink-0 items-center overflow-hidden rounded-md border bg-background">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 rounded-none px-2 text-xs"
|
||||
:class="diagramMode === 'table' ? 'bg-accent' : ''"
|
||||
@click="diagramMode = 'table'"
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="h-8 rounded-none px-2 text-xs" :class="diagramMode === 'table' ? 'bg-accent' : ''" @click="diagramMode = 'table'">
|
||||
<Table2 class="mr-1 h-3.5 w-3.5" />
|
||||
{{ t("diagram.tableMode") }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 rounded-none border-l px-2 text-xs"
|
||||
:class="diagramMode === 'engineering' ? 'bg-accent' : ''"
|
||||
@click="diagramMode = 'engineering'"
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="h-8 rounded-none border-l px-2 text-xs" :class="diagramMode === 'engineering' ? 'bg-accent' : ''" @click="diagramMode = 'engineering'">
|
||||
<Network class="mr-1 h-3.5 w-3.5" />
|
||||
{{ t("diagram.engineeringMode") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="focusTableName && tables.length > 0"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 px-2 text-xs"
|
||||
@click="showAllTables = !showAllTables"
|
||||
>
|
||||
<Button v-if="focusTableName && tables.length > 0" variant="outline" size="sm" class="h-8 px-2 text-xs" @click="showAllTables = !showAllTables">
|
||||
{{ showAllTables ? t("diagram.relatedTables") : t("diagram.allTables") }}
|
||||
</Button>
|
||||
|
||||
|
|
@ -786,24 +676,10 @@ onUnmounted(stopDrag);
|
|||
{{ t("diagram.relationshipsCount", { count: visibleRelationships.length }) }}
|
||||
</Badge>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:disabled="loadingDiagram || visibleTables.length === 0"
|
||||
:title="t('diagram.exportSvg')"
|
||||
@click="exportSvg"
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" :disabled="loadingDiagram || visibleTables.length === 0" :title="t('diagram.exportSvg')" @click="exportSvg">
|
||||
<Download class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:disabled="!diagramReady || loadingDiagram"
|
||||
:title="t('diagram.refresh')"
|
||||
@click="loadDiagram"
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" :disabled="!diagramReady || loadingDiagram" :title="t('diagram.refresh')" @click="loadDiagram">
|
||||
<Loader2 v-if="loadingDiagram" class="h-4 w-4 animate-spin" />
|
||||
<RefreshCw v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -813,13 +689,7 @@ onUnmounted(stopDrag);
|
|||
<Button variant="ghost" size="icon" class="h-8 w-8" :title="t('diagram.zoomIn')" @click="zoomIn">
|
||||
<ZoomIn class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:title="t('diagram.resetLayout')"
|
||||
@click="resetZoomAndLayout"
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" :title="t('diagram.resetLayout')" @click="resetZoomAndLayout">
|
||||
<Maximize2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -832,26 +702,13 @@ onUnmounted(stopDrag);
|
|||
<div v-else-if="!diagramReady" class="h-full flex items-center justify-center text-sm text-muted-foreground">
|
||||
{{ t("diagram.selectTarget") }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="tables.length === 0"
|
||||
class="h-full flex items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
<div v-else-if="tables.length === 0" class="h-full flex items-center justify-center text-sm text-muted-foreground">
|
||||
{{ t("diagram.empty") }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="visibleTables.length === 0"
|
||||
class="h-full flex items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
<div v-else-if="visibleTables.length === 0" class="h-full flex items-center justify-center text-sm text-muted-foreground">
|
||||
{{ t("diagram.noMatches") }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
ref="diagramViewport"
|
||||
class="h-full overflow-auto"
|
||||
@wheel="onDiagramWheel"
|
||||
@gesturestart="onDiagramGestureStart"
|
||||
@gesturechange="onDiagramGestureChange"
|
||||
>
|
||||
<div v-else ref="diagramViewport" class="h-full overflow-auto" @wheel="onDiagramWheel" @gesturestart="onDiagramGestureStart" @gesturechange="onDiagramGestureChange">
|
||||
<div
|
||||
class="relative"
|
||||
:style="{
|
||||
|
|
@ -870,26 +727,11 @@ onUnmounted(stopDrag);
|
|||
<template v-if="diagramMode === 'table'">
|
||||
<svg class="absolute inset-0 h-full w-full overflow-visible pointer-events-none">
|
||||
<defs>
|
||||
<marker
|
||||
id="diagram-arrow"
|
||||
markerWidth="8"
|
||||
markerHeight="8"
|
||||
refX="7"
|
||||
refY="4"
|
||||
orient="auto"
|
||||
markerUnits="strokeWidth"
|
||||
>
|
||||
<marker id="diagram-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M 0 0 L 8 4 L 0 8 z" class="fill-primary/70" />
|
||||
</marker>
|
||||
</defs>
|
||||
<path
|
||||
v-for="relationship in visibleRelationships"
|
||||
:key="relationship.id"
|
||||
:d="relationshipPath(relationship)"
|
||||
class="fill-none stroke-primary/55"
|
||||
stroke-width="1.6"
|
||||
marker-end="url(#diagram-arrow)"
|
||||
>
|
||||
<path v-for="relationship in visibleRelationships" :key="relationship.id" :d="relationshipPath(relationship)" class="fill-none stroke-primary/55" stroke-width="1.6" marker-end="url(#diagram-arrow)">
|
||||
<title>{{ relationshipTitle(relationship) }}</title>
|
||||
</path>
|
||||
</svg>
|
||||
|
|
@ -904,20 +746,13 @@ onUnmounted(stopDrag);
|
|||
transform: `translate(${positions[table.name]?.x ?? 0}px, ${positions[table.name]?.y ?? 0}px)`,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="flex h-11 cursor-grab items-center gap-2 border-b bg-muted/40 px-3 active:cursor-grabbing"
|
||||
@mousedown="startDrag(table.name, $event)"
|
||||
>
|
||||
<div class="flex h-11 cursor-grab items-center gap-2 border-b bg-muted/40 px-3 active:cursor-grabbing" @mousedown="startDrag(table.name, $event)">
|
||||
<Table2 class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium">{{ table.name }}</span>
|
||||
<Badge variant="outline" class="h-5 px-1.5 text-[10px]">{{ table.columns.length }}</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
v-for="column in visibleColumns(table)"
|
||||
:key="column.name"
|
||||
class="flex h-6 items-center gap-1.5 border-b border-border/40 px-3 text-xs last:border-b-0"
|
||||
>
|
||||
<div v-for="column in visibleColumns(table)" :key="column.name" class="flex h-6 items-center gap-1.5 border-b border-border/40 px-3 text-xs last:border-b-0">
|
||||
<KeyRound v-if="column.is_primary_key" class="h-3 w-3 shrink-0 text-amber-500" />
|
||||
<Link2 v-else-if="isForeignKeyColumn(table, column.name)" class="h-3 w-3 shrink-0 text-primary" />
|
||||
<span v-else class="h-3 w-3 shrink-0" />
|
||||
|
|
@ -944,51 +779,19 @@ onUnmounted(stopDrag);
|
|||
stroke-width="1.2"
|
||||
/>
|
||||
<template v-for="relationship in engineeringDiagram.relationships" :key="relationship.id">
|
||||
<line
|
||||
:x1="engineeringEntityCenter(relationship.sourceTable).x"
|
||||
:y1="engineeringEntityCenter(relationship.sourceTable).y"
|
||||
:x2="engineeringRelationshipCenter(relationship).x"
|
||||
:y2="engineeringRelationshipCenter(relationship).y"
|
||||
stroke-width="1.4"
|
||||
/>
|
||||
<line
|
||||
:x1="engineeringRelationshipCenter(relationship).x"
|
||||
:y1="engineeringRelationshipCenter(relationship).y"
|
||||
:x2="engineeringEntityCenter(relationship.targetTable).x"
|
||||
:y2="engineeringEntityCenter(relationship.targetTable).y"
|
||||
stroke-width="1.4"
|
||||
/>
|
||||
<line :x1="engineeringEntityCenter(relationship.sourceTable).x" :y1="engineeringEntityCenter(relationship.sourceTable).y" :x2="engineeringRelationshipCenter(relationship).x" :y2="engineeringRelationshipCenter(relationship).y" stroke-width="1.4" />
|
||||
<line :x1="engineeringRelationshipCenter(relationship).x" :y1="engineeringRelationshipCenter(relationship).y" :x2="engineeringEntityCenter(relationship.targetTable).x" :y2="engineeringEntityCenter(relationship.targetTable).y" stroke-width="1.4" />
|
||||
<text
|
||||
class="fill-foreground text-[13px] font-semibold"
|
||||
:x="
|
||||
engineeringCardinalityPoint(
|
||||
engineeringRelationshipCenter(relationship),
|
||||
engineeringEntityCenter(relationship.sourceTable),
|
||||
).x
|
||||
"
|
||||
:y="
|
||||
engineeringCardinalityPoint(
|
||||
engineeringRelationshipCenter(relationship),
|
||||
engineeringEntityCenter(relationship.sourceTable),
|
||||
).y
|
||||
"
|
||||
:x="engineeringCardinalityPoint(engineeringRelationshipCenter(relationship), engineeringEntityCenter(relationship.sourceTable)).x"
|
||||
:y="engineeringCardinalityPoint(engineeringRelationshipCenter(relationship), engineeringEntityCenter(relationship.sourceTable)).y"
|
||||
>
|
||||
{{ relationship.sourceCardinality }}
|
||||
</text>
|
||||
<text
|
||||
class="fill-foreground text-[13px] font-semibold"
|
||||
:x="
|
||||
engineeringCardinalityPoint(
|
||||
engineeringRelationshipCenter(relationship),
|
||||
engineeringEntityCenter(relationship.targetTable),
|
||||
).x
|
||||
"
|
||||
:y="
|
||||
engineeringCardinalityPoint(
|
||||
engineeringRelationshipCenter(relationship),
|
||||
engineeringEntityCenter(relationship.targetTable),
|
||||
).y
|
||||
"
|
||||
:x="engineeringCardinalityPoint(engineeringRelationshipCenter(relationship), engineeringEntityCenter(relationship.targetTable)).x"
|
||||
:y="engineeringCardinalityPoint(engineeringRelationshipCenter(relationship), engineeringEntityCenter(relationship.targetTable)).y"
|
||||
>
|
||||
{{ relationship.targetCardinality }}
|
||||
</text>
|
||||
|
|
@ -1022,10 +825,7 @@ onUnmounted(stopDrag);
|
|||
}"
|
||||
:title="`${relationship.sourceTable} -> ${relationship.targetTable}`"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 border border-red-500/70 bg-red-100/80 dark:bg-red-950/35"
|
||||
style="clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%)"
|
||||
/>
|
||||
<div class="absolute inset-0 border border-red-500/70 bg-red-100/80 dark:bg-red-950/35" style="clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%)" />
|
||||
<span class="relative max-w-[70px] truncate">{{ relationship.label }}</span>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -11,28 +11,11 @@ import { useToast } from "@/composables/useToast";
|
|||
import { databaseOptionsForConnection } from "@/composables/useDatabaseOptions";
|
||||
import { isSchemaAware } from "@/lib/databaseCapabilities";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import type {
|
||||
DataCompareCellValue,
|
||||
DataCompareModifiedRow,
|
||||
DataCompareResult,
|
||||
DataCompareRow,
|
||||
DataCompareSyncPlan,
|
||||
DataCompareSyncPlanTableOptions,
|
||||
} from "@/lib/dataCompare";
|
||||
import type { DataCompareCellValue, DataCompareModifiedRow, DataCompareResult, DataCompareRow, DataCompareSyncPlan, DataCompareSyncPlanTableOptions } from "@/lib/dataCompare";
|
||||
import type { ColumnInfo, DatabaseType } from "@/types/database";
|
||||
import * as api from "@/lib/api";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import {
|
||||
ArrowLeftRight,
|
||||
CheckSquare,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
GitCompareArrows,
|
||||
Loader2,
|
||||
Play,
|
||||
Square,
|
||||
} from "@lucide/vue";
|
||||
import { ArrowLeftRight, CheckSquare, ChevronDown, ChevronRight, Copy, GitCompareArrows, Loader2, Play, Square } from "@lucide/vue";
|
||||
|
||||
type CompareColumn = ColumnInfo;
|
||||
|
||||
|
|
@ -131,23 +114,15 @@ const showModified = ref(true);
|
|||
|
||||
let syncPlanRequestId = 0;
|
||||
|
||||
const sqlConnections = computed(() =>
|
||||
store.connections.filter((connection) => !["redis", "mongodb", "elasticsearch", "etcd"].includes(connection.db_type)),
|
||||
);
|
||||
const selectedSourceTableNames = computed(() =>
|
||||
sourceTables.value.filter((table) => selectedSourceTables.value.has(table)),
|
||||
);
|
||||
const sqlConnections = computed(() => store.connections.filter((connection) => !["redis", "mongodb", "elasticsearch", "etcd"].includes(connection.db_type)));
|
||||
const selectedSourceTableNames = computed(() => sourceTables.value.filter((table) => selectedSourceTables.value.has(table)));
|
||||
const isBatchCompare = computed(() => selectedSourceTableNames.value.length > 1);
|
||||
const filteredSourceTables = computed(() => {
|
||||
const query = sourceTableSearch.value.trim().toLowerCase();
|
||||
if (!query) return sourceTables.value;
|
||||
return sourceTables.value.filter((table) => table.toLowerCase().includes(query));
|
||||
});
|
||||
const allFilteredTablesSelected = computed(
|
||||
() =>
|
||||
filteredSourceTables.value.length > 0 &&
|
||||
filteredSourceTables.value.every((table) => selectedSourceTables.value.has(table)),
|
||||
);
|
||||
const allFilteredTablesSelected = computed(() => filteredSourceTables.value.length > 0 && filteredSourceTables.value.every((table) => selectedSourceTables.value.has(table)));
|
||||
const compareTasksPreview = computed(() =>
|
||||
selectedSourceTableNames.value.map((table) => {
|
||||
const target = isBatchCompare.value ? table : targetTable.value || table;
|
||||
|
|
@ -160,19 +135,8 @@ const compareTasksPreview = computed(() =>
|
|||
}),
|
||||
);
|
||||
const matchedTaskCount = computed(() => compareTasksPreview.value.filter((task) => task.matched).length);
|
||||
const missingTargetTables = computed(() =>
|
||||
compareTasksPreview.value.filter((task) => !task.matched).map((task) => task.targetTable || task.sourceTable),
|
||||
);
|
||||
const canCompare = computed(
|
||||
() =>
|
||||
sourceConnectionId.value &&
|
||||
sourceDatabase.value &&
|
||||
sourceSchema.value &&
|
||||
selectedSourceTableNames.value.length > 0 &&
|
||||
targetConnectionId.value &&
|
||||
targetDatabase.value &&
|
||||
targetSchema.value,
|
||||
);
|
||||
const missingTargetTables = computed(() => compareTasksPreview.value.filter((task) => !task.matched).map((task) => task.targetTable || task.sourceTable));
|
||||
const canCompare = computed(() => sourceConnectionId.value && sourceDatabase.value && sourceSchema.value && selectedSourceTableNames.value.length > 0 && targetConnectionId.value && targetDatabase.value && targetSchema.value);
|
||||
const keyColumns = computed(() =>
|
||||
keyColumnsText.value
|
||||
.split(",")
|
||||
|
|
@ -188,11 +152,7 @@ const totalAdded = computed(() => batchResults.value.reduce((sum, item) => sum +
|
|||
const totalRemoved = computed(() => batchResults.value.reduce((sum, item) => sum + item.removed, 0));
|
||||
const totalModified = computed(() => batchResults.value.reduce((sum, item) => sum + item.modified, 0));
|
||||
const hasResults = computed(() => batchResults.value.length > 0);
|
||||
const visibleKinds = computed(() => [
|
||||
...(showAdded.value ? (["added"] as DiffKind[]) : []),
|
||||
...(showRemoved.value ? (["removed"] as DiffKind[]) : []),
|
||||
...(showModified.value ? (["modified"] as DiffKind[]) : []),
|
||||
]);
|
||||
const visibleKinds = computed(() => [...(showAdded.value ? (["added"] as DiffKind[]) : []), ...(showRemoved.value ? (["removed"] as DiffKind[]) : []), ...(showModified.value ? (["modified"] as DiffKind[]) : [])]);
|
||||
const selectedAddedCount = computed(() => selectedDiffCount("added"));
|
||||
const selectedRemovedCount = computed(() => selectedDiffCount("removed"));
|
||||
const selectedModifiedCount = computed(() => selectedDiffCount("modified"));
|
||||
|
|
@ -300,8 +260,7 @@ function clearResult() {
|
|||
function swapSourceTarget() {
|
||||
const previousSelectedTables = [...selectedSourceTableNames.value];
|
||||
const nextSingleTarget = previousSelectedTables.length === 1 ? (previousSelectedTables[0] ?? "") : "";
|
||||
const nextSourceSelection =
|
||||
previousSelectedTables.length <= 1 ? [targetTable.value].filter(Boolean) : previousSelectedTables;
|
||||
const nextSourceSelection = previousSelectedTables.length <= 1 ? [targetTable.value].filter(Boolean) : previousSelectedTables;
|
||||
|
||||
const tmpConnId = sourceConnectionId.value;
|
||||
const tmpDb = sourceDatabase.value;
|
||||
|
|
@ -358,12 +317,7 @@ async function loadSchemas(side: "source" | "target", preferredSchema = "") {
|
|||
}
|
||||
|
||||
const schemas = await api.listSchemas(connectionId, database);
|
||||
const schema =
|
||||
preferredSchema && schemas.includes(preferredSchema)
|
||||
? preferredSchema
|
||||
: schemas.includes("public")
|
||||
? "public"
|
||||
: (schemas[0] ?? "");
|
||||
const schema = preferredSchema && schemas.includes(preferredSchema) ? preferredSchema : schemas.includes("public") ? "public" : (schemas[0] ?? "");
|
||||
if (side === "source") {
|
||||
sourceSchemas.value = schemas;
|
||||
sourceSchema.value = schema;
|
||||
|
|
@ -402,19 +356,11 @@ async function loadTables(side: "source" | "target") {
|
|||
const connectionId = side === "source" ? sourceConnectionId.value : targetConnectionId.value;
|
||||
const database = side === "source" ? sourceDatabase.value : targetDatabase.value;
|
||||
if (!connectionId || !database) return;
|
||||
const schema =
|
||||
side === "source"
|
||||
? sourceSchema.value || (await resolveSchema(connectionId, database, props.prefillSchema))
|
||||
: targetSchema.value || (await resolveSchema(connectionId, database));
|
||||
const tables = (await api.listTables(connectionId, database, schema))
|
||||
.filter((table) => table.table_type !== "VIEW")
|
||||
.map((table) => table.name);
|
||||
const schema = side === "source" ? sourceSchema.value || (await resolveSchema(connectionId, database, props.prefillSchema)) : targetSchema.value || (await resolveSchema(connectionId, database));
|
||||
const tables = (await api.listTables(connectionId, database, schema)).filter((table) => table.table_type !== "VIEW").map((table) => table.name);
|
||||
|
||||
if (side === "source") {
|
||||
const preferredSelection =
|
||||
props.prefillTable && tables.includes(props.prefillTable)
|
||||
? [props.prefillTable]
|
||||
: [...selectedSourceTables.value].filter((table) => tables.includes(table));
|
||||
const preferredSelection = props.prefillTable && tables.includes(props.prefillTable) ? [props.prefillTable] : [...selectedSourceTables.value].filter((table) => tables.includes(table));
|
||||
sourceSchema.value = schema;
|
||||
sourceTables.value = tables;
|
||||
resetSelectedSourceTables(preferredSelection);
|
||||
|
|
@ -423,23 +369,12 @@ async function loadTables(side: "source" | "target") {
|
|||
targetSchema.value = schema;
|
||||
targetTables.value = tables;
|
||||
const singleSourceTable = selectedSourceTableNames.value.length === 1 ? selectedSourceTableNames.value[0] : "";
|
||||
const preferred =
|
||||
targetTable.value && tables.includes(targetTable.value)
|
||||
? targetTable.value
|
||||
: singleSourceTable && tables.includes(singleSourceTable)
|
||||
? singleSourceTable
|
||||
: "";
|
||||
const preferred = targetTable.value && tables.includes(targetTable.value) ? targetTable.value : singleSourceTable && tables.includes(singleSourceTable) ? singleSourceTable : "";
|
||||
targetTable.value = preferred;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadColumnsWithCache(
|
||||
cache: Map<string, CompareColumn[]>,
|
||||
connectionId: string,
|
||||
database: string,
|
||||
schema: string,
|
||||
table: string,
|
||||
): Promise<CompareColumn[]> {
|
||||
async function loadColumnsWithCache(cache: Map<string, CompareColumn[]>, connectionId: string, database: string, schema: string, table: string): Promise<CompareColumn[]> {
|
||||
const key = `${connectionId}:${database}:${schema}:${table}`;
|
||||
const cached = cache.get(key);
|
||||
if (cached) return cached;
|
||||
|
|
@ -448,25 +383,9 @@ async function loadColumnsWithCache(
|
|||
return columns;
|
||||
}
|
||||
|
||||
async function inferKeyColumnsForTable(
|
||||
table: string,
|
||||
sourceColumnCache?: Map<string, CompareColumn[]>,
|
||||
): Promise<string[]> {
|
||||
async function inferKeyColumnsForTable(table: string, sourceColumnCache?: Map<string, CompareColumn[]>): Promise<string[]> {
|
||||
if (!sourceConnectionId.value || !sourceDatabase.value || !sourceSchema.value || !table) return [];
|
||||
const columns = sourceColumnCache
|
||||
? await loadColumnsWithCache(
|
||||
sourceColumnCache,
|
||||
sourceConnectionId.value,
|
||||
sourceDatabase.value,
|
||||
sourceSchema.value,
|
||||
table,
|
||||
)
|
||||
: (((await api.getColumns(
|
||||
sourceConnectionId.value,
|
||||
sourceDatabase.value,
|
||||
sourceSchema.value,
|
||||
table,
|
||||
)) as CompareColumn[]) ?? []);
|
||||
const columns = sourceColumnCache ? await loadColumnsWithCache(sourceColumnCache, sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, table) : (((await api.getColumns(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, table)) as CompareColumn[]) ?? []);
|
||||
const primaryKeys = columns.filter((column) => column.is_primary_key).map((column) => column.name);
|
||||
if (primaryKeys.length > 0) return primaryKeys;
|
||||
return columns.slice(0, 1).map((column) => column.name);
|
||||
|
|
@ -591,13 +510,7 @@ function buildSyncPlanTables(): DataCompareSyncPlanTableOptions[] {
|
|||
databaseType: table.databaseType,
|
||||
preSyncStatements: table.preSyncStatements ?? [],
|
||||
}))
|
||||
.filter(
|
||||
(table) =>
|
||||
table.preSyncStatements.length > 0 ||
|
||||
table.diff.added.length > 0 ||
|
||||
table.diff.removed.length > 0 ||
|
||||
table.diff.modified.length > 0,
|
||||
);
|
||||
.filter((table) => table.preSyncStatements.length > 0 || table.diff.added.length > 0 || table.diff.removed.length > 0 || table.diff.modified.length > 0);
|
||||
}
|
||||
|
||||
async function rebuildSyncPlan() {
|
||||
|
|
@ -640,10 +553,7 @@ async function startCompare() {
|
|||
const currentTargetDatabaseType = targetDatabaseType();
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
store.ensureConnected(sourceConnectionId.value),
|
||||
store.ensureConnected(targetConnectionId.value),
|
||||
]);
|
||||
await Promise.all([store.ensureConnected(sourceConnectionId.value), store.ensureConnected(targetConnectionId.value)]);
|
||||
|
||||
for (const [index, task] of tasks.entries()) {
|
||||
compareProgressCurrent.value = index + 1;
|
||||
|
|
@ -651,13 +561,7 @@ async function startCompare() {
|
|||
|
||||
try {
|
||||
if (!targetTables.value.includes(task.targetTable)) {
|
||||
const sourceColumns = await loadColumnsWithCache(
|
||||
sourceColumnCache,
|
||||
sourceConnectionId.value,
|
||||
sourceDatabase.value,
|
||||
sourceSchema.value,
|
||||
task.sourceTable,
|
||||
);
|
||||
const sourceColumns = await loadColumnsWithCache(sourceColumnCache, sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, task.sourceTable);
|
||||
const resolvedKeys = keyColumns.value.length > 0 ? keyColumns.value : [];
|
||||
const preparation = await api.prepareDataCompareMissingTarget({
|
||||
sourceConnectionId: sourceConnectionId.value,
|
||||
|
|
@ -697,34 +601,15 @@ async function startCompare() {
|
|||
continue;
|
||||
}
|
||||
|
||||
const resolvedKeys =
|
||||
keyColumns.value.length > 0
|
||||
? keyColumns.value
|
||||
: await inferKeyColumnsForTable(task.sourceTable, sourceColumnCache);
|
||||
const resolvedKeys = keyColumns.value.length > 0 ? keyColumns.value : await inferKeyColumnsForTable(task.sourceTable, sourceColumnCache);
|
||||
if (resolvedKeys.length === 0) {
|
||||
throw new Error(t("dataCompare.noKeyColumns"));
|
||||
}
|
||||
|
||||
const sourceColumns = await loadColumnsWithCache(
|
||||
sourceColumnCache,
|
||||
sourceConnectionId.value,
|
||||
sourceDatabase.value,
|
||||
sourceSchema.value,
|
||||
task.sourceTable,
|
||||
);
|
||||
const targetColumns = await loadColumnsWithCache(
|
||||
targetColumnCache,
|
||||
targetConnectionId.value,
|
||||
targetDatabase.value,
|
||||
targetSchema.value,
|
||||
task.targetTable,
|
||||
);
|
||||
const columns = sourceColumns
|
||||
.map((column) => column.name)
|
||||
.filter((column) => targetColumns.some((target) => target.name === column));
|
||||
const columnInfo = columns
|
||||
.map((column) => targetColumns.find((target) => target.name === column))
|
||||
.filter((column): column is CompareColumn => !!column);
|
||||
const sourceColumns = await loadColumnsWithCache(sourceColumnCache, sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, task.sourceTable);
|
||||
const targetColumns = await loadColumnsWithCache(targetColumnCache, targetConnectionId.value, targetDatabase.value, targetSchema.value, task.targetTable);
|
||||
const columns = sourceColumns.map((column) => column.name).filter((column) => targetColumns.some((target) => target.name === column));
|
||||
const columnInfo = columns.map((column) => targetColumns.find((target) => target.name === column)).filter((column): column is CompareColumn => !!column);
|
||||
const missingKeys = resolvedKeys.filter((column) => !columns.includes(column));
|
||||
if (missingKeys.length > 0) {
|
||||
throw new Error(t("dataCompare.missingKeyColumns", { columns: missingKeys.join(", ") }));
|
||||
|
|
@ -883,12 +768,7 @@ function formatRowValues(values: Record<string, DataCompareCellValue>): string {
|
|||
}
|
||||
|
||||
function formatModifiedSummary(row: SelectableDataCompareModifiedRow): string {
|
||||
return truncateText(
|
||||
row.changes
|
||||
.map((change) => `${change.column}: ${formatValue(change.target)} -> ${formatValue(change.source)}`)
|
||||
.join(", "),
|
||||
220,
|
||||
);
|
||||
return truncateText(row.changes.map((change) => `${change.column}: ${formatValue(change.target)} -> ${formatValue(change.source)}`).join(", "), 220);
|
||||
}
|
||||
|
||||
watch(sourceConnectionId, (id) => {
|
||||
|
|
@ -999,17 +879,10 @@ watch(
|
|||
<div class="grid grid-cols-[1fr_auto_1fr] gap-4 items-start">
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs font-medium">{{ t("diff.source") }}</Label>
|
||||
<Select
|
||||
:model-value="sourceConnectionId"
|
||||
@update:model-value="(v: any) => (sourceConnectionId = String(v))"
|
||||
>
|
||||
<Select :model-value="sourceConnectionId" @update:model-value="(v: any) => (sourceConnectionId = String(v))">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<DatabaseIcon
|
||||
v-if="sourceConnectionId"
|
||||
:db-type="connectionIconType(sourceConnectionId)"
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
<DatabaseIcon v-if="sourceConnectionId" :db-type="connectionIconType(sourceConnectionId)" class="w-3.5 h-3.5" />
|
||||
<SelectValue :placeholder="t('diff.selectConnection')" />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
|
|
@ -1022,16 +895,10 @@ watch(
|
|||
<Select :model-value="sourceDatabase" @update:model-value="(v: any) => (sourceDatabase = String(v))">
|
||||
<SelectTrigger class="h-8 text-xs"><SelectValue :placeholder="t('diff.selectDatabase')" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="database in sourceDatabases" :key="database" :value="database">{{
|
||||
database
|
||||
}}</SelectItem>
|
||||
<SelectItem v-for="database in sourceDatabases" :key="database" :value="database">{{ database }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
v-if="sourceSchemas.length"
|
||||
:model-value="sourceSchema"
|
||||
@update:model-value="(v: any) => (sourceSchema = String(v))"
|
||||
>
|
||||
<Select v-if="sourceSchemas.length" :model-value="sourceSchema" @update:model-value="(v: any) => (sourceSchema = String(v))">
|
||||
<SelectTrigger class="h-8 text-xs"><SelectValue :placeholder="t('diff.selectSchema')" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="schema in sourceSchemas" :key="schema" :value="schema">{{ schema }}</SelectItem>
|
||||
|
|
@ -1051,24 +918,11 @@ watch(
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
v-if="sourceTables.length > 5"
|
||||
v-model="sourceTableSearch"
|
||||
class="h-7 text-xs"
|
||||
:placeholder="t('dataCompare.searchTables')"
|
||||
/>
|
||||
<Input v-if="sourceTables.length > 5" v-model="sourceTableSearch" class="h-7 text-xs" :placeholder="t('dataCompare.searchTables')" />
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="sourceTables.length"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-xs"
|
||||
@click="toggleSelectAllSourceTables"
|
||||
>
|
||||
{{
|
||||
allFilteredTablesSelected ? t("dataCompare.deselectAllTables") : t("dataCompare.selectAllTables")
|
||||
}}
|
||||
<Button v-if="sourceTables.length" variant="outline" size="sm" class="h-7 px-2 text-xs" @click="toggleSelectAllSourceTables">
|
||||
{{ allFilteredTablesSelected ? t("dataCompare.deselectAllTables") : t("dataCompare.selectAllTables") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
@ -1079,13 +933,7 @@ watch(
|
|||
{{ t("dataCompare.noTables") }}
|
||||
</div>
|
||||
<div v-else class="max-h-40 overflow-auto rounded border">
|
||||
<button
|
||||
v-for="table in filteredSourceTables"
|
||||
:key="table"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-xs hover:bg-muted/50"
|
||||
@click="toggleSourceTable(table)"
|
||||
>
|
||||
<button v-for="table in filteredSourceTables" :key="table" type="button" class="flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-xs hover:bg-muted/50" @click="toggleSourceTable(table)">
|
||||
<CheckSquare v-if="selectedSourceTables.has(table)" class="w-3.5 h-3.5 text-primary shrink-0" />
|
||||
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
|
||||
<span class="truncate">{{ table }}</span>
|
||||
|
|
@ -1102,17 +950,10 @@ watch(
|
|||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs font-medium">{{ t("diff.target") }}</Label>
|
||||
<Select
|
||||
:model-value="targetConnectionId"
|
||||
@update:model-value="(v: any) => (targetConnectionId = String(v))"
|
||||
>
|
||||
<Select :model-value="targetConnectionId" @update:model-value="(v: any) => (targetConnectionId = String(v))">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<DatabaseIcon
|
||||
v-if="targetConnectionId"
|
||||
:db-type="connectionIconType(targetConnectionId)"
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
<DatabaseIcon v-if="targetConnectionId" :db-type="connectionIconType(targetConnectionId)" class="w-3.5 h-3.5" />
|
||||
<SelectValue :placeholder="t('diff.selectConnection')" />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
|
|
@ -1125,16 +966,10 @@ watch(
|
|||
<Select :model-value="targetDatabase" @update:model-value="(v: any) => (targetDatabase = String(v))">
|
||||
<SelectTrigger class="h-8 text-xs"><SelectValue :placeholder="t('diff.selectDatabase')" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="database in targetDatabases" :key="database" :value="database">{{
|
||||
database
|
||||
}}</SelectItem>
|
||||
<SelectItem v-for="database in targetDatabases" :key="database" :value="database">{{ database }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
v-if="targetSchemas.length"
|
||||
:model-value="targetSchema"
|
||||
@update:model-value="(v: any) => (targetSchema = String(v))"
|
||||
>
|
||||
<Select v-if="targetSchemas.length" :model-value="targetSchema" @update:model-value="(v: any) => (targetSchema = String(v))">
|
||||
<SelectTrigger class="h-8 text-xs"><SelectValue :placeholder="t('diff.selectSchema')" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="schema in targetSchemas" :key="schema" :value="schema">{{ schema }}</SelectItem>
|
||||
|
|
@ -1155,19 +990,13 @@ watch(
|
|||
<div v-else class="space-y-2 rounded-lg border p-3 text-xs">
|
||||
<div class="font-medium">{{ t("dataCompare.autoMatchHint") }}</div>
|
||||
<div class="text-muted-foreground">
|
||||
{{
|
||||
t("dataCompare.matchedTables", { matched: matchedTaskCount, total: selectedSourceTableNames.length })
|
||||
}}
|
||||
{{ t("dataCompare.matchedTables", { matched: matchedTaskCount, total: selectedSourceTableNames.length }) }}
|
||||
</div>
|
||||
<div v-if="missingTargetTables.length" class="text-destructive">
|
||||
{{ t("dataCompare.missingTargetTables", { tables: missingTargetTables.join(", ") }) }}
|
||||
</div>
|
||||
<div v-if="compareTasksPreview.length" class="max-h-36 overflow-auto rounded border bg-muted/20">
|
||||
<div
|
||||
v-for="task in compareTasksPreview"
|
||||
:key="`${task.sourceTable}:${task.targetTable}`"
|
||||
class="flex items-center justify-between gap-2 border-b px-2 py-1 last:border-b-0"
|
||||
>
|
||||
<div v-for="task in compareTasksPreview" :key="`${task.sourceTable}:${task.targetTable}`" class="flex items-center justify-between gap-2 border-b px-2 py-1 last:border-b-0">
|
||||
<span class="truncate font-mono">{{ task.sourceTable }}</span>
|
||||
<span class="text-muted-foreground">→</span>
|
||||
<span class="truncate font-mono" :class="task.matched ? '' : 'text-destructive'">
|
||||
|
|
@ -1195,33 +1024,9 @@ watch(
|
|||
|
||||
<div class="rounded-lg border p-3 space-y-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-7 text-xs"
|
||||
:class="showAdded ? 'border-primary' : ''"
|
||||
@click="showAdded = !showAdded"
|
||||
>
|
||||
{{ t("diff.added") }} · {{ totalAdded }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-7 text-xs"
|
||||
:class="showRemoved ? 'border-primary' : ''"
|
||||
@click="showRemoved = !showRemoved"
|
||||
>
|
||||
{{ t("diff.removed") }} · {{ totalRemoved }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-7 text-xs"
|
||||
:class="showModified ? 'border-primary' : ''"
|
||||
@click="showModified = !showModified"
|
||||
>
|
||||
{{ t("diff.modified") }} · {{ totalModified }}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" class="h-7 text-xs" :class="showAdded ? 'border-primary' : ''" @click="showAdded = !showAdded"> {{ t("diff.added") }} · {{ totalAdded }} </Button>
|
||||
<Button size="sm" variant="outline" class="h-7 text-xs" :class="showRemoved ? 'border-primary' : ''" @click="showRemoved = !showRemoved"> {{ t("diff.removed") }} · {{ totalRemoved }} </Button>
|
||||
<Button size="sm" variant="outline" class="h-7 text-xs" :class="showModified ? 'border-primary' : ''" @click="showModified = !showModified"> {{ t("diff.modified") }} · {{ totalModified }} </Button>
|
||||
<span class="flex-1" />
|
||||
<Select v-model="detailPreviewLimit">
|
||||
<SelectTrigger class="h-7 w-32 text-xs">
|
||||
|
|
@ -1297,10 +1102,7 @@ watch(
|
|||
<div v-if="item.status === 'different'" class="mt-1">
|
||||
{{
|
||||
t("dataCompare.selectedInline", {
|
||||
selected:
|
||||
selectedRows(item, "added") +
|
||||
selectedRows(item, "removed") +
|
||||
selectedRows(item, "modified"),
|
||||
selected: selectedRows(item, "added") + selectedRows(item, "removed") + selectedRows(item, "modified"),
|
||||
total: item.added + item.removed + item.modified,
|
||||
})
|
||||
}}
|
||||
|
|
@ -1314,16 +1116,8 @@ watch(
|
|||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="item in batchResults.filter((entry) => entry.status === 'different')"
|
||||
:key="`details-${item.sourceTable}:${item.targetTable}`"
|
||||
class="rounded-lg border overflow-hidden"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 border-b bg-muted/30 px-3 py-2 text-left text-sm font-medium"
|
||||
@click="toggleTableExpanded(item)"
|
||||
>
|
||||
<div v-for="item in batchResults.filter((entry) => entry.status === 'different')" :key="`details-${item.sourceTable}:${item.targetTable}`" class="rounded-lg border overflow-hidden">
|
||||
<button type="button" class="flex w-full items-center gap-2 border-b bg-muted/30 px-3 py-2 text-left text-sm font-medium" @click="toggleTableExpanded(item)">
|
||||
<ChevronDown v-if="item.expanded" class="h-4 w-4 shrink-0" />
|
||||
<ChevronRight v-else class="h-4 w-4 shrink-0" />
|
||||
<span class="font-mono">{{ item.sourceTable }}</span>
|
||||
|
|
@ -1332,76 +1126,36 @@ watch(
|
|||
</button>
|
||||
|
||||
<div v-if="item.expanded" class="space-y-3 p-3">
|
||||
<div
|
||||
v-for="kind in visibleKinds"
|
||||
:key="`${item.sourceTable}:${kind}`"
|
||||
class="rounded-lg border"
|
||||
v-show="hasDiffRows(item, kind)"
|
||||
>
|
||||
<div v-for="kind in visibleKinds" :key="`${item.sourceTable}:${kind}`" class="rounded-lg border" v-show="hasDiffRows(item, kind)">
|
||||
<div class="flex flex-wrap items-center gap-2 border-b bg-muted/20 px-3 py-2 text-xs">
|
||||
<span class="font-medium">{{ t(`diff.${kind}`) }}</span>
|
||||
<span class="text-muted-foreground"
|
||||
>{{ selectedRows(item, kind) }}/{{ item.diff[kind].length }}</span
|
||||
>
|
||||
<span class="text-muted-foreground">{{ selectedRows(item, kind) }}/{{ item.diff[kind].length }}</span>
|
||||
<span class="flex-1" />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="h-6 px-2 text-xs"
|
||||
@click="setTableDiffSelection(item, kind, true)"
|
||||
>
|
||||
<Button size="sm" variant="ghost" class="h-6 px-2 text-xs" @click="setTableDiffSelection(item, kind, true)">
|
||||
{{ t("dataCompare.selectAllKind", { kind: t(`diff.${kind}`) }) }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="h-6 px-2 text-xs"
|
||||
@click="setTableDiffSelection(item, kind, false)"
|
||||
>
|
||||
<Button size="sm" variant="ghost" class="h-6 px-2 text-xs" @click="setTableDiffSelection(item, kind, false)">
|
||||
{{ t("dataCompare.clearKind", { kind: t(`diff.${kind}`) }) }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="item.diff[kind].length > detailPreviewLimitNumber"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="h-6 px-2 text-xs"
|
||||
@click="toggleShowAll(item, kind)"
|
||||
>
|
||||
{{
|
||||
item.showAll[kind]
|
||||
? t("dataCompare.showLessRows")
|
||||
: t("dataCompare.showAllRows", { count: item.diff[kind].length })
|
||||
}}
|
||||
<Button v-if="item.diff[kind].length > detailPreviewLimitNumber" size="sm" variant="ghost" class="h-6 px-2 text-xs" @click="toggleShowAll(item, kind)">
|
||||
{{ item.showAll[kind] ? t("dataCompare.showLessRows") : t("dataCompare.showAllRows", { count: item.diff[kind].length }) }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="max-h-72 overflow-auto divide-y">
|
||||
<button
|
||||
v-for="row in rowsForDisplay(item, kind)"
|
||||
:key="`${item.sourceTable}:${kind}:${row.key}`"
|
||||
type="button"
|
||||
class="flex w-full items-start gap-3 px-3 py-2 text-left text-xs hover:bg-muted/40"
|
||||
@click="toggleRowSelection(row)"
|
||||
>
|
||||
<button v-for="row in rowsForDisplay(item, kind)" :key="`${item.sourceTable}:${kind}:${row.key}`" type="button" class="flex w-full items-start gap-3 px-3 py-2 text-left text-xs hover:bg-muted/40" @click="toggleRowSelection(row)">
|
||||
<CheckSquare v-if="row.selected" class="mt-0.5 h-3.5 w-3.5 shrink-0 text-primary" />
|
||||
<Square v-else class="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground/40" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-mono">{{ formatKeyValues(row.keyValues) }}</div>
|
||||
<div class="mt-1 text-muted-foreground break-words">
|
||||
{{
|
||||
kind === "modified"
|
||||
? formatModifiedSummary(row as SelectableDataCompareModifiedRow)
|
||||
: formatRowValues((row as SelectableDataCompareRow).values)
|
||||
}}
|
||||
{{ kind === "modified" ? formatModifiedSummary(row as SelectableDataCompareModifiedRow) : formatRowValues((row as SelectableDataCompareRow).values) }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="remainingRows(item, kind) > 0 && !item.showAll[kind]"
|
||||
class="border-t px-3 py-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<div v-if="remainingRows(item, kind) > 0 && !item.showAll[kind]" class="border-t px-3 py-2 text-xs text-muted-foreground">
|
||||
{{ t("dataCompare.remainingRows", { count: remainingRows(item, kind) }) }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1414,11 +1168,7 @@ watch(
|
|||
</div>
|
||||
<div v-else-if="syncPlan.syncSql.trim()" class="space-y-1">
|
||||
<Label class="text-xs font-medium">{{ t("diff.generatedSql") }}</Label>
|
||||
<textarea
|
||||
:value="syncPlan.syncSql"
|
||||
readonly
|
||||
class="w-full h-48 rounded-lg border bg-muted/20 p-3 font-mono text-xs resize-none focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<textarea :value="syncPlan.syncSql" readonly class="w-full h-48 rounded-lg border bg-muted/20 p-3 font-mono text-xs resize-none focus:outline-none focus:ring-1 focus:ring-ring" />
|
||||
</div>
|
||||
<div v-else-if="differentTableCount === 0 && failedTableCount === 0" class="text-sm text-muted-foreground">
|
||||
{{ t("dataCompare.noDifferences") }}
|
||||
|
|
@ -1435,9 +1185,7 @@ watch(
|
|||
<div class="max-h-32 overflow-auto border rounded-lg bg-destructive/5 p-2 space-y-1">
|
||||
<div v-for="(err, i) in syncErrors" :key="i" class="text-xs font-mono">
|
||||
<span class="text-destructive">{{ err.error }}</span>
|
||||
<span class="text-muted-foreground ml-1"
|
||||
>— {{ err.sql.slice(0, 80) }}{{ err.sql.length > 80 ? "..." : "" }}</span
|
||||
>
|
||||
<span class="text-muted-foreground ml-1">— {{ err.sql.slice(0, 80) }}{{ err.sql.length > 80 ? "..." : "" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1445,9 +1193,7 @@ watch(
|
|||
|
||||
<DialogFooter v-if="!hasResults">
|
||||
<Button variant="outline" @click="open = false">{{ t("common.close") }}</Button>
|
||||
<span v-if="compareProgressLabel" class="text-xs text-muted-foreground self-center">{{
|
||||
compareProgressLabel
|
||||
}}</span>
|
||||
<span v-if="compareProgressLabel" class="text-xs text-muted-foreground self-center">{{ compareProgressLabel }}</span>
|
||||
<Button size="sm" :disabled="!canCompare || comparing" @click="startCompare">
|
||||
<Loader2 v-if="comparing" class="w-3.5 h-3.5 animate-spin mr-1" />
|
||||
<GitCompareArrows v-else class="w-3.5 h-3.5 mr-1" />
|
||||
|
|
@ -1473,9 +1219,7 @@ watch(
|
|||
})
|
||||
}}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" :disabled="!syncPlan.syncSql.trim()" @click="copySql">
|
||||
<Copy class="w-3 h-3 mr-1" /> {{ t("diff.copySql") }}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" :disabled="!syncPlan.syncSql.trim()" @click="copySql"> <Copy class="w-3 h-3 mr-1" /> {{ t("diff.copySql") }} </Button>
|
||||
<Button size="sm" :disabled="planningSync || executing || syncPlan.statementCount === 0" @click="executeSql">
|
||||
<Loader2 v-if="executing" class="w-3 h-3 animate-spin mr-1" />
|
||||
<Play v-else class="w-3 h-3 mr-1" />
|
||||
|
|
|
|||
|
|
@ -67,19 +67,9 @@ function toggleAll() {
|
|||
refreshSelectedSyncSql().catch((e) => toast(e?.message || String(e), 5000));
|
||||
}
|
||||
|
||||
const sqlConnections = computed(() =>
|
||||
store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "etcd"].includes(c.db_type)),
|
||||
);
|
||||
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "etcd"].includes(c.db_type)));
|
||||
|
||||
const canCompare = computed(
|
||||
() =>
|
||||
sourceConnectionId.value &&
|
||||
sourceDatabase.value &&
|
||||
sourceSchema.value &&
|
||||
targetConnectionId.value &&
|
||||
targetDatabase.value &&
|
||||
targetSchema.value,
|
||||
);
|
||||
const canCompare = computed(() => sourceConnectionId.value && sourceDatabase.value && sourceSchema.value && targetConnectionId.value && targetDatabase.value && targetSchema.value);
|
||||
|
||||
function connectionIconType(connectionId: string) {
|
||||
const config = store.getConfig(connectionId);
|
||||
|
|
@ -151,12 +141,7 @@ async function loadSchemas(side: "source" | "target", preferredSchema = "") {
|
|||
}
|
||||
|
||||
const schemas = await api.listSchemas(connectionId, database);
|
||||
const selected =
|
||||
preferredSchema && schemas.includes(preferredSchema)
|
||||
? preferredSchema
|
||||
: schemas.includes("public")
|
||||
? "public"
|
||||
: (schemas[0] ?? "");
|
||||
const selected = preferredSchema && schemas.includes(preferredSchema) ? preferredSchema : schemas.includes("public") ? "public" : (schemas[0] ?? "");
|
||||
if (side === "source") {
|
||||
sourceSchemas.value = schemas;
|
||||
sourceSchema.value = selected;
|
||||
|
|
@ -177,10 +162,7 @@ async function startCompare() {
|
|||
await store.ensureConnected(targetConnectionId.value);
|
||||
const targetConfig = store.getConfig(targetConnectionId.value);
|
||||
|
||||
const [srcTables, tgtTables] = await Promise.all([
|
||||
api.listTables(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value),
|
||||
api.listTables(targetConnectionId.value, targetDatabase.value, targetSchema.value),
|
||||
]);
|
||||
const [srcTables, tgtTables] = await Promise.all([api.listTables(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value), api.listTables(targetConnectionId.value, targetDatabase.value, targetSchema.value)]);
|
||||
|
||||
const { sourceDetails, targetDetails } = await loadSchemaDiffDetails(srcTables, tgtTables);
|
||||
const result = await api.prepareSchemaDiff({
|
||||
|
|
@ -379,10 +361,7 @@ watch(
|
|||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent
|
||||
class="min-w-[min(720px,calc(100vw-2rem))] resize-x sm:max-w-5xl max-h-[80vh] flex flex-col overflow-hidden"
|
||||
@interact-outside.prevent
|
||||
>
|
||||
<DialogContent class="min-w-[min(720px,calc(100vw-2rem))] resize-x sm:max-w-5xl max-h-[80vh] flex flex-col overflow-hidden" @interact-outside.prevent>
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<GitCompareArrows class="w-4 h-4" />
|
||||
|
|
@ -395,17 +374,10 @@ watch(
|
|||
<div class="grid grid-cols-[1fr_auto_1fr] gap-4 items-start">
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs font-medium">{{ t("diff.source") }}</Label>
|
||||
<Select
|
||||
:model-value="sourceConnectionId"
|
||||
@update:model-value="(v: any) => (sourceConnectionId = String(v))"
|
||||
>
|
||||
<Select :model-value="sourceConnectionId" @update:model-value="(v: any) => (sourceConnectionId = String(v))">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<DatabaseIcon
|
||||
v-if="sourceConnectionId"
|
||||
:db-type="connectionIconType(sourceConnectionId)"
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
<DatabaseIcon v-if="sourceConnectionId" :db-type="connectionIconType(sourceConnectionId)" class="w-3.5 h-3.5" />
|
||||
<SelectValue :placeholder="t('diff.selectConnection')" />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
|
|
@ -418,11 +390,7 @@ watch(
|
|||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
v-if="sourceDatabases.length"
|
||||
:model-value="sourceDatabase"
|
||||
@update:model-value="(v: any) => (sourceDatabase = String(v))"
|
||||
>
|
||||
<Select v-if="sourceDatabases.length" :model-value="sourceDatabase" @update:model-value="(v: any) => (sourceDatabase = String(v))">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue :placeholder="t('diff.selectDatabase')" />
|
||||
</SelectTrigger>
|
||||
|
|
@ -430,11 +398,7 @@ watch(
|
|||
<SelectItem v-for="db in sourceDatabases" :key="db" :value="db">{{ db }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
v-if="sourceSchemas.length"
|
||||
:model-value="sourceSchema"
|
||||
@update:model-value="(v: any) => (sourceSchema = String(v))"
|
||||
>
|
||||
<Select v-if="sourceSchemas.length" :model-value="sourceSchema" @update:model-value="(v: any) => (sourceSchema = String(v))">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue :placeholder="t('diff.selectSchema')" />
|
||||
</SelectTrigger>
|
||||
|
|
@ -452,17 +416,10 @@ watch(
|
|||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs font-medium">{{ t("diff.target") }}</Label>
|
||||
<Select
|
||||
:model-value="targetConnectionId"
|
||||
@update:model-value="(v: any) => (targetConnectionId = String(v))"
|
||||
>
|
||||
<Select :model-value="targetConnectionId" @update:model-value="(v: any) => (targetConnectionId = String(v))">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<DatabaseIcon
|
||||
v-if="targetConnectionId"
|
||||
:db-type="connectionIconType(targetConnectionId)"
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
<DatabaseIcon v-if="targetConnectionId" :db-type="connectionIconType(targetConnectionId)" class="w-3.5 h-3.5" />
|
||||
<SelectValue :placeholder="t('diff.selectConnection')" />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
|
|
@ -475,11 +432,7 @@ watch(
|
|||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
v-if="targetDatabases.length"
|
||||
:model-value="targetDatabase"
|
||||
@update:model-value="(v: any) => (targetDatabase = String(v))"
|
||||
>
|
||||
<Select v-if="targetDatabases.length" :model-value="targetDatabase" @update:model-value="(v: any) => (targetDatabase = String(v))">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue :placeholder="t('diff.selectDatabase')" />
|
||||
</SelectTrigger>
|
||||
|
|
@ -487,11 +440,7 @@ watch(
|
|||
<SelectItem v-for="db in targetDatabases" :key="db" :value="db">{{ db }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
v-if="targetSchemas.length"
|
||||
:model-value="targetSchema"
|
||||
@update:model-value="(v: any) => (targetSchema = String(v))"
|
||||
>
|
||||
<Select v-if="targetSchemas.length" :model-value="targetSchema" @update:model-value="(v: any) => (targetSchema = String(v))">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue :placeholder="t('diff.selectSchema')" />
|
||||
</SelectTrigger>
|
||||
|
|
@ -529,13 +478,7 @@ watch(
|
|||
<thead class="bg-muted sticky top-0 z-10">
|
||||
<tr>
|
||||
<th class="px-2 py-2 w-8">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="accent-primary"
|
||||
:checked="allSelected"
|
||||
:indeterminate="someSelected"
|
||||
@change="toggleAll"
|
||||
/>
|
||||
<input type="checkbox" class="accent-primary" :checked="allSelected" :indeterminate="someSelected" @change="toggleAll" />
|
||||
</th>
|
||||
<th class="text-left px-3 py-2 font-medium w-1/4">{{ t("diff.table") }}</th>
|
||||
<th class="text-left px-3 py-2 font-medium w-16">{{ t("diff.status") }}</th>
|
||||
|
|
@ -545,12 +488,7 @@ watch(
|
|||
<tbody>
|
||||
<tr v-for="d in diffs" :key="d.name" class="border-t border-border/50 hover:bg-accent/30">
|
||||
<td class="px-2 py-1.5">
|
||||
<input
|
||||
v-model="d.selected"
|
||||
type="checkbox"
|
||||
class="accent-primary"
|
||||
@change="onDiffSelectionChange"
|
||||
/>
|
||||
<input v-model="d.selected" type="checkbox" class="accent-primary" @change="onDiffSelectionChange" />
|
||||
</td>
|
||||
<td class="px-3 py-1.5 font-mono truncate">{{ d.name }}</td>
|
||||
<td class="px-3 py-1.5">
|
||||
|
|
@ -612,17 +550,13 @@ watch(
|
|||
'text-red-500': trigger.type === 'removed',
|
||||
'text-yellow-500': trigger.type === 'modified',
|
||||
}"
|
||||
>{{ trigger.type === "added" ? "+" : trigger.type === "removed" ? "-" : "~"
|
||||
}}{{ trigger.name }}</span
|
||||
>{{ trigger.type === "added" ? "+" : trigger.type === "removed" ? "-" : "~" }}{{ trigger.name }}</span
|
||||
>
|
||||
<span v-if="ti < d.triggers!.length - 1">, </span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="d.type === 'modified' && d.sourceTableComment !== undefined">
|
||||
<span
|
||||
v-if="d.columns?.length || d.indexes?.length || d.foreignKeys?.length || d.triggers?.length"
|
||||
>;
|
||||
</span>
|
||||
<span v-if="d.columns?.length || d.indexes?.length || d.foreignKeys?.length || d.triggers?.length">; </span>
|
||||
<span>{{ t("diff.comments") }}</span>
|
||||
</template>
|
||||
<span v-else-if="d.type === 'added'" class="text-green-500">{{ t("diff.newTable") }}</span>
|
||||
|
|
@ -637,10 +571,7 @@ watch(
|
|||
<!-- SQL Preview -->
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs font-medium">{{ t("diff.generatedSql") }}</Label>
|
||||
<pre
|
||||
class="w-full h-48 overflow-auto rounded-lg border bg-muted/20 p-3 font-mono text-xs whitespace-pre"
|
||||
v-html="highlightedSyncSql"
|
||||
></pre>
|
||||
<pre class="w-full h-48 overflow-auto rounded-lg border bg-muted/20 p-3 font-mono text-xs whitespace-pre" v-html="highlightedSyncSql"></pre>
|
||||
</div>
|
||||
|
||||
<!-- Sync Errors -->
|
||||
|
|
@ -651,9 +582,7 @@ watch(
|
|||
<div class="max-h-32 overflow-auto border rounded-lg bg-destructive/5 p-2 space-y-1">
|
||||
<div v-for="(err, i) in syncErrors" :key="i" class="text-xs font-mono">
|
||||
<span class="text-destructive">{{ err.error }}</span>
|
||||
<span class="text-muted-foreground ml-1"
|
||||
>— {{ err.sql.slice(0, 80) }}{{ err.sql.length > 80 ? "..." : "" }}</span
|
||||
>
|
||||
<span class="text-muted-foreground ml-1">— {{ err.sql.slice(0, 80) }}{{ err.sql.length > 80 ? "..." : "" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -674,9 +603,7 @@ watch(
|
|||
<span v-if="executing" class="text-xs text-muted-foreground mr-auto">
|
||||
{{ t("diff.syncProgress", { current: executedCount, total: executeTotal }) }}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" @click="copySql">
|
||||
<Copy class="w-3 h-3 mr-1" /> {{ t("diff.copySql") }}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" @click="copySql"> <Copy class="w-3 h-3 mr-1" /> {{ t("diff.copySql") }} </Button>
|
||||
<Button size="sm" :disabled="!syncSql.trim() || executing" @click="executeSql">
|
||||
<Loader2 v-if="executing" class="w-3 h-3 animate-spin mr-1" />
|
||||
<Play v-else class="w-3 h-3 mr-1" />
|
||||
|
|
|
|||
|
|
@ -3,34 +3,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref } from "vue";
|
|||
import { uuid } from "@/lib/utils";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
import {
|
||||
ArrowUp,
|
||||
ArrowRightLeft,
|
||||
AlertTriangle,
|
||||
Bot,
|
||||
Check,
|
||||
ChevronRight,
|
||||
CircleSlash,
|
||||
Copy,
|
||||
Database,
|
||||
HelpCircle,
|
||||
History,
|
||||
Loader2,
|
||||
MessageSquarePlus,
|
||||
Replace,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
Table2,
|
||||
Play,
|
||||
Square,
|
||||
Trash2,
|
||||
Terminal,
|
||||
Wand2,
|
||||
Wrench,
|
||||
X,
|
||||
Zap,
|
||||
TestTube,
|
||||
} from "@lucide/vue";
|
||||
import { ArrowUp, ArrowRightLeft, AlertTriangle, Bot, Check, ChevronRight, CircleSlash, Copy, Database, HelpCircle, History, Loader2, MessageSquarePlus, Replace, Server, ShieldCheck, Table2, Play, Square, Trash2, Terminal, Wand2, Wrench, X, Zap, TestTube } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
|
@ -49,24 +22,11 @@ import { buildAiAgentStepItems, type AiAgentStepItem, type AiAgentStepTone } fro
|
|||
import { createAiShikiCodeHighlighter, type AiCodeHighlighter } from "@/lib/aiCodeHighlighter";
|
||||
import { createAiMessageRenderer } from "@/lib/aiMessageRender";
|
||||
import { Marked } from "marked";
|
||||
import {
|
||||
aiCancelStream,
|
||||
saveAiConversation,
|
||||
loadAiConversations,
|
||||
deleteAiConversation,
|
||||
listSchemas,
|
||||
listTables,
|
||||
type AiConversation,
|
||||
} from "@/lib/api";
|
||||
import { aiCancelStream, saveAiConversation, loadAiConversations, deleteAiConversation, listSchemas, listTables, type AiConversation } from "@/lib/api";
|
||||
import type { AiMessage } from "@/lib/api";
|
||||
import type { ConnectionConfig, QueryTab, TableInfo } from "@/types/database";
|
||||
import { useDatabaseOptions } from "@/composables/useDatabaseOptions";
|
||||
import {
|
||||
decodeSelectableDatabaseValue,
|
||||
encodeSelectableDatabaseValue,
|
||||
formatDatabaseLabel,
|
||||
resolveDefaultDatabase,
|
||||
} from "@/lib/defaultDatabase";
|
||||
import { decodeSelectableDatabaseValue, encodeSelectableDatabaseValue, formatDatabaseLabel, resolveDefaultDatabase } from "@/lib/defaultDatabase";
|
||||
import { isSchemaAware } from "@/lib/databaseCapabilities";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import { formatAiTableMention, parseAiTableMentions, type AiTableMention } from "@/lib/aiTableMentions";
|
||||
|
|
@ -162,9 +122,7 @@ const isWaitingForFirstDelta = computed(() => {
|
|||
return isGenerating.value && last?.role === "assistant" && !last.content && !last.reasoning;
|
||||
});
|
||||
|
||||
const activePlaceholder = computed(
|
||||
() => `${t(`ai.placeholders.${activeAction.value}`)} ${t("ai.tableMentionPlaceholderHint")}`,
|
||||
);
|
||||
const activePlaceholder = computed(() => `${t(`ai.placeholders.${activeAction.value}`)} ${t("ai.tableMentionPlaceholderHint")}`);
|
||||
const activeModeHint = computed(() => t(`ai.modeHints.${assistantMode.value}`));
|
||||
const assistantModeItems = computed(() => [
|
||||
{
|
||||
|
|
@ -209,9 +167,7 @@ const dbSelectOptions = computed(() => {
|
|||
}));
|
||||
});
|
||||
|
||||
const selectedDatabaseSelectValue = computed(() =>
|
||||
props.connection ? encodeSelectableDatabaseValue(props.connection.db_type, props.tab?.database || "") : "",
|
||||
);
|
||||
const selectedDatabaseSelectValue = computed(() => (props.connection ? encodeSelectableDatabaseValue(props.connection.db_type, props.tab?.database || "") : ""));
|
||||
|
||||
const selectedDatabaseLabel = computed(() => {
|
||||
if (!props.connection) return t("editor.selectDatabase");
|
||||
|
|
@ -373,18 +329,10 @@ async function loadMentionCandidates(query: string) {
|
|||
let candidates: AiMentionCandidate[] = [];
|
||||
if (isSchemaAware(props.connection.db_type)) {
|
||||
const schemas = mentionSchemaOrder(await listSchemas(props.tab.connectionId, props.tab.database));
|
||||
const filteredSchemas = schemaPrefix
|
||||
? schemas.filter((schema) => schema.toLowerCase().includes(schemaPrefix.toLowerCase()))
|
||||
: schemas;
|
||||
const filteredSchemas = schemaPrefix ? schemas.filter((schema) => schema.toLowerCase().includes(schemaPrefix.toLowerCase())) : schemas;
|
||||
const results = await Promise.all(
|
||||
filteredSchemas.slice(0, 8).map(async (schema) => {
|
||||
const tables = await listTables(
|
||||
props.tab!.connectionId,
|
||||
props.tab!.database,
|
||||
schema,
|
||||
tableFilter || undefined,
|
||||
20,
|
||||
);
|
||||
const tables = await listTables(props.tab!.connectionId, props.tab!.database, schema, tableFilter || undefined, 20);
|
||||
return filterMentionCandidates(
|
||||
tables.map((table) => mentionCandidateFromTable(table, schema)),
|
||||
tableFilter,
|
||||
|
|
@ -432,8 +380,7 @@ function removeMentionChip(mention: AiTableMention) {
|
|||
function addSelectedMention(candidate: AiMentionCandidate) {
|
||||
const raw = formatAiTableMention(candidate.schema, candidate.name);
|
||||
const key = `${candidate.schema || ""}.${candidate.name}`.toLowerCase();
|
||||
if (selectedMentions.value.some((mention) => `${mention.schema || ""}.${mention.table}`.toLowerCase() === key))
|
||||
return;
|
||||
if (selectedMentions.value.some((mention) => `${mention.schema || ""}.${mention.table}`.toLowerCase() === key)) return;
|
||||
selectedMentions.value.push({ raw, schema: candidate.schema, table: candidate.name });
|
||||
}
|
||||
|
||||
|
|
@ -445,15 +392,9 @@ function formatMentionTableType(tableType: string) {
|
|||
return t("ai.tableMentionTypes.table");
|
||||
}
|
||||
|
||||
function filterMentionCandidates(
|
||||
candidates: AiMentionCandidate[],
|
||||
tableFilter: string,
|
||||
limit: number,
|
||||
): AiMentionCandidate[] {
|
||||
function filterMentionCandidates(candidates: AiMentionCandidate[], tableFilter: string, limit: number): AiMentionCandidate[] {
|
||||
const normalizedFilter = tableFilter.toLowerCase();
|
||||
return candidates
|
||||
.filter((candidate) => !normalizedFilter || candidate.name.toLowerCase().includes(normalizedFilter))
|
||||
.slice(0, limit);
|
||||
return candidates.filter((candidate) => !normalizedFilter || candidate.name.toLowerCase().includes(normalizedFilter)).slice(0, limit);
|
||||
}
|
||||
|
||||
function refreshMentionState() {
|
||||
|
|
@ -492,10 +433,7 @@ function onPromptKeydown(event: KeyboardEvent) {
|
|||
if (mentionOpen.value) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
mentionSelectedIndex.value = Math.min(
|
||||
mentionSelectedIndex.value + 1,
|
||||
Math.max(mentionCandidates.value.length - 1, 0),
|
||||
);
|
||||
mentionSelectedIndex.value = Math.min(mentionSelectedIndex.value + 1, Math.max(mentionCandidates.value.length - 1, 0));
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
|
|
@ -718,10 +656,7 @@ const messageRenderer = computed(() => {
|
|||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col overflow-hidden">
|
||||
<div
|
||||
class="flex items-center gap-2 border-b px-3 shrink-0"
|
||||
:class="settings.editorSettings.appLayout === 'classic' ? 'h-9' : 'h-10'"
|
||||
>
|
||||
<div class="flex items-center gap-2 border-b px-3 shrink-0" :class="settings.editorSettings.appLayout === 'classic' ? 'h-9' : 'h-10'">
|
||||
<span class="flex flex-1 self-stretch items-center truncate text-xs font-medium" data-tauri-drag-region>
|
||||
{{ chatTitle }}
|
||||
</span>
|
||||
|
|
@ -730,13 +665,7 @@ const messageRenderer = computed(() => {
|
|||
</Button>
|
||||
<Popover :open="showConversationList" @update:open="setConversationListOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
:class="{ 'bg-accent': showConversationList }"
|
||||
:title="t('history.title')"
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6" :class="{ 'bg-accent': showConversationList }" :title="t('history.title')">
|
||||
<History class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
|
@ -751,18 +680,9 @@ const messageRenderer = computed(() => {
|
|||
{{ t("history.empty") }}
|
||||
</div>
|
||||
<div v-else class="max-h-64 overflow-auto p-1">
|
||||
<div
|
||||
v-for="conv in conversations"
|
||||
:key="conv.id"
|
||||
class="flex min-w-0 cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs hover:bg-muted"
|
||||
:class="{ 'bg-muted': conv.id === conversationId }"
|
||||
@click="selectConversation(conv)"
|
||||
>
|
||||
<div v-for="conv in conversations" :key="conv.id" class="flex min-w-0 cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs hover:bg-muted" :class="{ 'bg-muted': conv.id === conversationId }" @click="selectConversation(conv)">
|
||||
<span class="min-w-0 flex-1 truncate">{{ conv.title }}</span>
|
||||
<button
|
||||
class="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-background hover:text-destructive"
|
||||
@click.stop="deleteConversation(conv.id)"
|
||||
>
|
||||
<button class="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-background hover:text-destructive" @click.stop="deleteConversation(conv.id)">
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -777,10 +697,7 @@ const messageRenderer = computed(() => {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="messages.length === 0"
|
||||
class="flex-1 min-h-0 flex flex-col items-center justify-center text-center text-muted-foreground"
|
||||
>
|
||||
<div v-if="messages.length === 0" class="flex-1 min-h-0 flex flex-col items-center justify-center text-center text-muted-foreground">
|
||||
<Bot class="h-10 w-10 mb-3 opacity-30" />
|
||||
<p class="text-sm">{{ t("ai.welcome") }}</p>
|
||||
</div>
|
||||
|
|
@ -796,14 +713,8 @@ const messageRenderer = computed(() => {
|
|||
<div v-else-if="msg.content || msg.reasoning || msg.isThinking" class="flex">
|
||||
<div class="max-w-[95%] rounded-lg bg-muted px-3 py-2 text-xs leading-relaxed">
|
||||
<div v-if="msg.reasoning || msg.isThinking" class="mb-2">
|
||||
<button
|
||||
class="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
@click="toggleReasoning(i)"
|
||||
>
|
||||
<ChevronRight
|
||||
class="h-3 w-3 transition-transform duration-200"
|
||||
:class="{ 'rotate-90': expandedReasoning.has(i) || msg.isThinking }"
|
||||
/>
|
||||
<button class="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors" @click="toggleReasoning(i)">
|
||||
<ChevronRight class="h-3 w-3 transition-transform duration-200" :class="{ 'rotate-90': expandedReasoning.has(i) || msg.isThinking }" />
|
||||
<Loader2 v-if="msg.isThinking" class="h-3 w-3 animate-spin" />
|
||||
<span>{{ t("ai.reasoningProcess") }}</span>
|
||||
</button>
|
||||
|
|
@ -814,21 +725,13 @@ const messageRenderer = computed(() => {
|
|||
opacity: expandedReasoning.has(i) || msg.isThinking ? '1' : '0',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="mt-1.5 pl-4 border-l-2 border-muted-foreground/20 text-[11px] text-muted-foreground whitespace-pre-wrap"
|
||||
>
|
||||
<div class="mt-1.5 pl-4 border-l-2 border-muted-foreground/20 text-[11px] text-muted-foreground whitespace-pre-wrap">
|
||||
{{ msg.reasoning }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="msg.agentSteps?.length" class="mb-2 flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="step in msg.agentSteps"
|
||||
:key="step.key"
|
||||
class="inline-flex h-5 max-w-full items-center gap-1 rounded-full border px-1.5 text-[10px] font-medium"
|
||||
:class="agentStepClass(step.tone)"
|
||||
:title="agentStepTitle(step)"
|
||||
>
|
||||
<span v-for="step in msg.agentSteps" :key="step.key" class="inline-flex h-5 max-w-full items-center gap-1 rounded-full border px-1.5 text-[10px] font-medium" :class="agentStepClass(step.tone)" :title="agentStepTitle(step)">
|
||||
<component :is="agentStepIcon(step.tone)" class="h-3 w-3 shrink-0" />
|
||||
<span class="truncate">{{ t(step.labelKey) }}</span>
|
||||
</span>
|
||||
|
|
@ -837,38 +740,21 @@ const messageRenderer = computed(() => {
|
|||
<div v-if="seg.type === 'text'" class="ai-markdown whitespace-normal">
|
||||
<div v-html="seg.html" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="my-2 overflow-hidden rounded-md border border-zinc-200 bg-zinc-50 dark:border-zinc-700/50 dark:bg-zinc-900"
|
||||
>
|
||||
<div
|
||||
class="flex items-center border-b border-zinc-200 px-3 py-1.5 text-[10px] font-medium text-zinc-600 dark:border-zinc-700/50 dark:text-zinc-400"
|
||||
>
|
||||
<div v-else class="my-2 overflow-hidden rounded-md border border-zinc-200 bg-zinc-50 dark:border-zinc-700/50 dark:bg-zinc-900">
|
||||
<div class="flex items-center border-b border-zinc-200 px-3 py-1.5 text-[10px] font-medium text-zinc-600 dark:border-zinc-700/50 dark:text-zinc-400">
|
||||
<component :is="seg.isSql ? Database : Terminal" class="h-3 w-3 mr-1.5" />
|
||||
<span>{{ seg.lang }}</span>
|
||||
<span class="flex-1" />
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button
|
||||
v-if="seg.isSql"
|
||||
class="rounded p-0.5 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200"
|
||||
:title="t('ai.executeSql')"
|
||||
@click="executeSql(seg.content)"
|
||||
>
|
||||
<button v-if="seg.isSql" class="rounded p-0.5 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200" :title="t('ai.executeSql')" @click="executeSql(seg.content)">
|
||||
<Play class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
v-if="seg.isSql"
|
||||
class="rounded p-0.5 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200"
|
||||
:title="t('ai.apply')"
|
||||
@click="applySql(seg.content)"
|
||||
>
|
||||
<button v-if="seg.isSql" class="rounded p-0.5 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200" :title="t('ai.apply')" @click="applySql(seg.content)">
|
||||
<Replace class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded p-0.5 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200"
|
||||
:title="
|
||||
copiedIndex === `${i}-${j}` ? t('ai.copied') : t(seg.isSql ? 'ai.copySql' : 'ai.copyCode')
|
||||
"
|
||||
:title="copiedIndex === `${i}-${j}` ? t('ai.copied') : t(seg.isSql ? 'ai.copySql' : 'ai.copyCode')"
|
||||
@click="copyCode(seg.content, `${i}-${j}`)"
|
||||
>
|
||||
<Check v-if="copiedIndex === `${i}-${j}`" class="h-3.5 w-3.5 text-green-400" />
|
||||
|
|
@ -876,9 +762,7 @@ const messageRenderer = computed(() => {
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre
|
||||
class="ai-code-block whitespace-pre-wrap break-words p-3 text-xs leading-relaxed text-zinc-900 dark:text-zinc-100"
|
||||
><code v-html="seg.html"></code></pre>
|
||||
<pre class="ai-code-block whitespace-pre-wrap break-words p-3 text-xs leading-relaxed text-zinc-900 dark:text-zinc-100"><code v-html="seg.html"></code></pre>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
|
@ -898,12 +782,8 @@ const messageRenderer = computed(() => {
|
|||
<DatabaseIcon v-if="connection" :db-type="connectionIconType(connection)" class="h-3 w-3 shrink-0" />
|
||||
<Server v-else class="h-3 w-3 shrink-0" />
|
||||
<Select :model-value="connection?.id || ''" @update:model-value="(v: any) => changeConnection(v)">
|
||||
<SelectTrigger
|
||||
class="h-5 w-auto border-0 rounded-md bg-transparent dark:bg-transparent p-0 px-1 text-xs text-foreground/80 shadow-none focus:ring-0 focus-visible:ring-0 [&_svg]:size-3"
|
||||
>
|
||||
<SelectValue :placeholder="t('editor.selectConnection')">{{
|
||||
connection?.name || t("editor.selectConnection")
|
||||
}}</SelectValue>
|
||||
<SelectTrigger class="h-5 w-auto border-0 rounded-md bg-transparent dark:bg-transparent p-0 px-1 text-xs text-foreground/80 shadow-none focus:ring-0 focus-visible:ring-0 [&_svg]:size-3">
|
||||
<SelectValue :placeholder="t('editor.selectConnection')">{{ connection?.name || t("editor.selectConnection") }}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent class="min-w-48">
|
||||
<SelectItem v-for="conn in connectionStore.connections" :key="conn.id" :value="conn.id">
|
||||
|
|
@ -925,26 +805,17 @@ const messageRenderer = computed(() => {
|
|||
}
|
||||
"
|
||||
>
|
||||
<SelectTrigger
|
||||
class="h-5 w-auto border-0 rounded-md bg-transparent dark:bg-transparent p-0 px-1 text-xs text-foreground/80 shadow-none focus:ring-0 focus-visible:ring-0 [&_svg]:size-3"
|
||||
>
|
||||
<SelectTrigger class="h-5 w-auto border-0 rounded-md bg-transparent dark:bg-transparent p-0 px-1 text-xs text-foreground/80 shadow-none focus:ring-0 focus-visible:ring-0 [&_svg]:size-3">
|
||||
<SelectValue :placeholder="t('editor.selectDatabase')">{{ selectedDatabaseLabel }}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="option in dbSelectOptions" :key="option.value" :value="option.value">{{
|
||||
option.label
|
||||
}}</SelectItem>
|
||||
<SelectItem v-if="!dbSelectOptions.length && connection && tab" :value="selectedDatabaseSelectValue">{{
|
||||
selectedDatabaseLabel
|
||||
}}</SelectItem>
|
||||
<SelectItem v-for="option in dbSelectOptions" :key="option.value" :value="option.value">{{ option.label }}</SelectItem>
|
||||
<SelectItem v-if="!dbSelectOptions.length && connection && tab" :value="selectedDatabaseSelectValue">{{ selectedDatabaseLabel }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-if="mentionOpen"
|
||||
class="absolute bottom-full left-2 right-2 z-20 mb-1 max-h-56 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md"
|
||||
>
|
||||
<div v-if="mentionOpen" class="absolute bottom-full left-2 right-2 z-20 mb-1 max-h-56 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md">
|
||||
<div v-if="mentionLoading" class="flex items-center gap-2 px-2 py-2 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
<span>{{ t("common.loading") }}</span>
|
||||
|
|
@ -969,9 +840,7 @@ const messageRenderer = computed(() => {
|
|||
<span class="min-w-0 flex-1 truncate">
|
||||
<template v-if="candidate.schema">{{ candidate.schema }}.</template>{{ candidate.name }}
|
||||
</span>
|
||||
<span class="shrink-0 text-[10px] text-muted-foreground">{{
|
||||
formatMentionTableType(candidate.tableType)
|
||||
}}</span>
|
||||
<span class="shrink-0 text-[10px] text-muted-foreground">{{ formatMentionTableType(candidate.tableType) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1004,34 +873,13 @@ const messageRenderer = computed(() => {
|
|||
@keydown="onPromptKeydown"
|
||||
/>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<LightDropdown
|
||||
v-model="assistantMode"
|
||||
:items="assistantModeItems"
|
||||
:aria-label="activeModeHint"
|
||||
item-class="text-xs px-2"
|
||||
/>
|
||||
<LightDropdown
|
||||
:model-value="activeAction"
|
||||
:items="actionMenuItems"
|
||||
content-class="w-max min-w-0"
|
||||
item-class="text-xs px-2"
|
||||
@update:model-value="(value) => selectAction(value as AiAction)"
|
||||
/>
|
||||
<LightDropdown v-model="assistantMode" :items="assistantModeItems" :aria-label="activeModeHint" item-class="text-xs px-2" />
|
||||
<LightDropdown :model-value="activeAction" :items="actionMenuItems" content-class="w-max min-w-0" item-class="text-xs px-2" @update:model-value="(value) => selectAction(value as AiAction)" />
|
||||
<span class="flex-1" />
|
||||
<button
|
||||
v-if="isGenerating"
|
||||
class="h-7 w-7 shrink-0 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center"
|
||||
:title="t('ai.stopGenerating')"
|
||||
@click="cancelStream"
|
||||
>
|
||||
<button v-if="isGenerating" class="h-7 w-7 shrink-0 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center" :title="t('ai.stopGenerating')" @click="cancelStream">
|
||||
<Square class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="h-7 w-7 shrink-0 rounded-full bg-foreground text-background flex items-center justify-center disabled:opacity-30"
|
||||
:disabled="!prompt.trim() || !props.tab?.database"
|
||||
@click="send"
|
||||
>
|
||||
<button v-else class="h-7 w-7 shrink-0 rounded-full bg-foreground text-background flex items-center justify-center disabled:opacity-30" :disabled="!prompt.trim() || !props.tab?.database" @click="send">
|
||||
<ArrowUp class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -67,13 +67,7 @@ async function copyText(text: string) {
|
|||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-semibold text-sm text-foreground">{{ col.name }}</span>
|
||||
<Badge
|
||||
v-if="col.isPrimaryKey"
|
||||
variant="outline"
|
||||
class="h-4 px-1 text-[10px] border-primary/40 text-primary"
|
||||
>
|
||||
PK
|
||||
</Badge>
|
||||
<Badge v-if="col.isPrimaryKey" variant="outline" class="h-4 px-1 text-[10px] border-primary/40 text-primary"> PK </Badge>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" class="h-5 w-5 p-0" @click="copyText(col.name)">
|
||||
<Copy class="h-3 w-3" />
|
||||
|
|
|
|||
|
|
@ -60,18 +60,9 @@ function onConfirm() {
|
|||
|
||||
<div class="py-4 min-w-0">
|
||||
<p class="text-sm text-muted-foreground mb-3">{{ message || t("dangerDialog.message") }}</p>
|
||||
<pre
|
||||
v-if="code"
|
||||
class="text-xs bg-muted p-3 rounded overflow-auto max-h-40 min-w-0 font-mono whitespace-pre"
|
||||
v-html="highlightedCode"
|
||||
/>
|
||||
<div
|
||||
v-if="showSuppressToggle"
|
||||
class="mt-3 flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2"
|
||||
>
|
||||
<Label for="danger-confirm-suppress" class="text-sm leading-5">{{
|
||||
suppressToggleLabel || t("dangerDialog.suppressFuturePrompts")
|
||||
}}</Label>
|
||||
<pre v-if="code" class="text-xs bg-muted p-3 rounded overflow-auto max-h-40 min-w-0 font-mono whitespace-pre" v-html="highlightedCode" />
|
||||
<div v-if="showSuppressToggle" class="mt-3 flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<Label for="danger-confirm-suppress" class="text-sm leading-5">{{ suppressToggleLabel || t("dangerDialog.suppressFuturePrompts") }}</Label>
|
||||
<Switch id="danger-confirm-suppress" v-model="suppressFuturePrompts" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,15 +3,7 @@ import { ref, nextTick, onBeforeUnmount, watch } from "vue";
|
|||
import { useI18n } from "vue-i18n";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { EditorSelection } from "@codemirror/state";
|
||||
import {
|
||||
SearchQuery,
|
||||
setSearchQuery,
|
||||
openSearchPanel as cmOpenSearchPanel,
|
||||
findNext as cmFindNext,
|
||||
findPrevious as cmFindPrevious,
|
||||
replaceNext as cmReplaceNext,
|
||||
replaceAll as cmReplaceAll,
|
||||
} from "@codemirror/search";
|
||||
import { SearchQuery, setSearchQuery, openSearchPanel as cmOpenSearchPanel, findNext as cmFindNext, findPrevious as cmFindPrevious, replaceNext as cmReplaceNext, replaceAll as cmReplaceAll } from "@codemirror/search";
|
||||
import { ChevronUp, ChevronDown, ChevronRight, X } from "@lucide/vue";
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -221,22 +213,10 @@ defineExpose({ openSearch, openReplace, closeSearch });
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-150"
|
||||
leave-active-class="transition-all duration-100"
|
||||
enter-from-class="opacity-0 -translate-y-1"
|
||||
leave-to-class="opacity-0 -translate-y-1"
|
||||
>
|
||||
<div
|
||||
v-if="searchVisible"
|
||||
class="absolute top-1 right-4 z-[9999] isolate flex flex-col gap-1 rounded-md border bg-popover p-1.5 text-popover-foreground shadow-lg"
|
||||
>
|
||||
<Transition enter-active-class="transition-all duration-150" leave-active-class="transition-all duration-100" enter-from-class="opacity-0 -translate-y-1" leave-to-class="opacity-0 -translate-y-1">
|
||||
<div v-if="searchVisible" class="absolute top-1 right-4 z-[9999] isolate flex flex-col gap-1 rounded-md border bg-popover p-1.5 text-popover-foreground shadow-lg">
|
||||
<div class="flex items-center gap-0.5">
|
||||
<button
|
||||
class="w-5 h-5 flex items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
:title="showReplace ? t('editor.search.collapseReplace') : t('editor.search.expandReplace')"
|
||||
@click="showReplace = !showReplace"
|
||||
>
|
||||
<button class="w-5 h-5 flex items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground" :title="showReplace ? t('editor.search.collapseReplace') : t('editor.search.expandReplace')" @click="showReplace = !showReplace">
|
||||
<ChevronRight class="w-3 h-3 transition-transform" :class="showReplace && 'rotate-90'" />
|
||||
</button>
|
||||
<input
|
||||
|
|
@ -249,48 +229,18 @@ defineExpose({ openSearch, openReplace, closeSearch });
|
|||
:placeholder="t('editor.search.find')"
|
||||
@keydown="onSearchKeydown"
|
||||
/>
|
||||
<button
|
||||
class="w-6 h-6 flex items-center justify-center rounded text-xs font-mono hover:bg-accent"
|
||||
:class="caseSensitive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'"
|
||||
:title="t('editor.search.caseSensitive')"
|
||||
@click="caseSensitive = !caseSensitive"
|
||||
>
|
||||
Aa
|
||||
</button>
|
||||
<button
|
||||
class="w-6 h-6 flex items-center justify-center rounded text-xs font-mono hover:bg-accent"
|
||||
:class="useRegex ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'"
|
||||
:title="t('editor.search.regex')"
|
||||
@click="useRegex = !useRegex"
|
||||
>
|
||||
.*
|
||||
</button>
|
||||
<button class="w-6 h-6 flex items-center justify-center rounded text-xs font-mono hover:bg-accent" :class="caseSensitive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'" :title="t('editor.search.caseSensitive')" @click="caseSensitive = !caseSensitive">Aa</button>
|
||||
<button class="w-6 h-6 flex items-center justify-center rounded text-xs font-mono hover:bg-accent" :class="useRegex ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'" :title="t('editor.search.regex')" @click="useRegex = !useRegex">.*</button>
|
||||
<span class="text-xs text-muted-foreground min-w-[3rem] text-center shrink-0">
|
||||
{{
|
||||
searchText && matchCount > 0
|
||||
? `${currentMatchIndex}/${matchCount}${matchCountLimited ? "+" : ""}`
|
||||
: t("editor.search.noResults")
|
||||
}}
|
||||
{{ searchText && matchCount > 0 ? `${currentMatchIndex}/${matchCount}${matchCountLimited ? "+" : ""}` : t("editor.search.noResults") }}
|
||||
</span>
|
||||
<button
|
||||
class="w-5 h-5 flex items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
:title="t('editor.search.prevMatch')"
|
||||
@click="prevMatch"
|
||||
>
|
||||
<button class="w-5 h-5 flex items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground" :title="t('editor.search.prevMatch')" @click="prevMatch">
|
||||
<ChevronUp class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
class="w-5 h-5 flex items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
:title="t('editor.search.nextMatch')"
|
||||
@click="nextMatch"
|
||||
>
|
||||
<button class="w-5 h-5 flex items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground" :title="t('editor.search.nextMatch')" @click="nextMatch">
|
||||
<ChevronDown class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
class="w-5 h-5 flex items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
:title="t('editor.search.close')"
|
||||
@click="closeSearch"
|
||||
>
|
||||
<button class="w-5 h-5 flex items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground" :title="t('editor.search.close')" @click="closeSearch">
|
||||
<X class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -307,18 +257,10 @@ defineExpose({ openSearch, openReplace, closeSearch });
|
|||
@keydown.enter.prevent="doReplace"
|
||||
@keydown.escape.prevent="closeSearch"
|
||||
/>
|
||||
<button
|
||||
class="h-6 px-1.5 flex items-center justify-center rounded text-xs text-muted-foreground hover:bg-accent hover:text-foreground border"
|
||||
:title="t('editor.search.replace')"
|
||||
@click="doReplace"
|
||||
>
|
||||
<button class="h-6 px-1.5 flex items-center justify-center rounded text-xs text-muted-foreground hover:bg-accent hover:text-foreground border" :title="t('editor.search.replace')" @click="doReplace">
|
||||
{{ t("editor.search.replace") }}
|
||||
</button>
|
||||
<button
|
||||
class="h-6 px-1.5 flex items-center justify-center rounded text-xs text-muted-foreground hover:bg-accent hover:text-foreground border"
|
||||
:title="t('editor.search.replaceAll')"
|
||||
@click="doReplaceAll"
|
||||
>
|
||||
<button class="h-6 px-1.5 flex items-center justify-center rounded text-xs text-muted-foreground hover:bg-accent hover:text-foreground border" :title="t('editor.search.replaceAll')" @click="doReplaceAll">
|
||||
{{ t("editor.search.replaceAll") }}
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,15 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
ref,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
onActivated,
|
||||
onDeactivated,
|
||||
watch,
|
||||
shallowRef,
|
||||
computed,
|
||||
nextTick,
|
||||
} from "vue";
|
||||
import { ref, onMounted, onBeforeUnmount, onActivated, onDeactivated, watch, shallowRef, computed, nextTick } from "vue";
|
||||
import { Play, Copy, TextSelect } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { CompletionContext } from "@codemirror/autocomplete";
|
||||
|
|
@ -24,23 +14,8 @@ import { useConnectionStore } from "@/stores/connectionStore";
|
|||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import {
|
||||
buildSqlCompletionItemsFromContext,
|
||||
getSqlFunctionSignatureHelp,
|
||||
getSqlCompletionContext,
|
||||
getSqlCompletionResultValidFor,
|
||||
isSqlLikeCompletionStatement,
|
||||
recordCompletionSelection,
|
||||
shouldAutoOpenSqlCompletion,
|
||||
extractCteDefinitions,
|
||||
} from "@/lib/sqlCompletion";
|
||||
import {
|
||||
buildElasticsearchCompletionItemsFromContext,
|
||||
getElasticsearchCompletionContext,
|
||||
getElasticsearchCompletionResultValidFor,
|
||||
shouldAutoOpenElasticsearchCompletion,
|
||||
type ElasticsearchCompletionItem,
|
||||
} from "@/lib/elasticsearchCompletion";
|
||||
import { buildSqlCompletionItemsFromContext, getSqlFunctionSignatureHelp, getSqlCompletionContext, getSqlCompletionResultValidFor, isSqlLikeCompletionStatement, recordCompletionSelection, shouldAutoOpenSqlCompletion, extractCteDefinitions } from "@/lib/sqlCompletion";
|
||||
import { buildElasticsearchCompletionItemsFromContext, getElasticsearchCompletionContext, getElasticsearchCompletionResultValidFor, shouldAutoOpenElasticsearchCompletion, type ElasticsearchCompletionItem } from "@/lib/elasticsearchCompletion";
|
||||
import { extractIdentifierAt, isSqlKeyword, matchTable } from "@/lib/sqlNavigation";
|
||||
import { lineColumnToOffset, parseSqlErrorLocation } from "@/lib/sqlDiagnostics";
|
||||
import {
|
||||
|
|
@ -54,43 +29,15 @@ import {
|
|||
type QueryEditorTableReferenceDropDetail,
|
||||
type QueryEditorTableReferencePayload,
|
||||
} from "@/lib/queryEditorTableDrop";
|
||||
import {
|
||||
EDITOR_FONT_FAMILY_CSS_VAR,
|
||||
EDITOR_FONT_SIZE_CSS_VAR,
|
||||
loadEditorTheme,
|
||||
editorFontTheme,
|
||||
sqlCompletionTheme,
|
||||
} from "@/lib/editorThemes";
|
||||
import {
|
||||
clampEditorFontSize,
|
||||
createEditorZoomCommitScheduler,
|
||||
fontSizeFromGestureScale,
|
||||
fontSizeFromWheelDelta,
|
||||
} from "@/lib/editorZoom";
|
||||
import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, loadEditorTheme, editorFontTheme, sqlCompletionTheme } from "@/lib/editorThemes";
|
||||
import { clampEditorFontSize, createEditorZoomCommitScheduler, fontSizeFromGestureScale, fontSizeFromWheelDelta } from "@/lib/editorZoom";
|
||||
import { shortcutToCodeMirrorKey } from "@/lib/shortcutRegistry";
|
||||
import { trimmedSelectionLayer } from "@/lib/codemirrorTrimmedSelectionLayer";
|
||||
import { selectionMatchOccurrences } from "@/lib/codemirrorSelectionMatches";
|
||||
import * as api from "@/lib/api";
|
||||
import {
|
||||
areSqlSemanticDiagnosticsEqual,
|
||||
buildSqlParserErrorDiagnostic,
|
||||
buildSqlSemanticDiagnostics,
|
||||
shouldRunSqlSemanticDiagnostics,
|
||||
type SqlSemanticDiagnostic,
|
||||
} from "@/lib/sqlSemanticDiagnostics";
|
||||
import type {
|
||||
SqlCompletionColumn,
|
||||
SqlCompletionForeignKey,
|
||||
SqlCompletionItem,
|
||||
SqlCompletionObject,
|
||||
} from "@/lib/sqlCompletion";
|
||||
import type {
|
||||
DatabaseType,
|
||||
ForeignKeyInfo,
|
||||
SqlReferenceAnalysis,
|
||||
SqlTableReference,
|
||||
SqlTextSpan,
|
||||
} from "@/types/database";
|
||||
import { areSqlSemanticDiagnosticsEqual, buildSqlParserErrorDiagnostic, buildSqlSemanticDiagnostics, shouldRunSqlSemanticDiagnostics, type SqlSemanticDiagnostic } from "@/lib/sqlSemanticDiagnostics";
|
||||
import type { SqlCompletionColumn, SqlCompletionForeignKey, SqlCompletionItem, SqlCompletionObject } from "@/lib/sqlCompletion";
|
||||
import type { DatabaseType, ForeignKeyInfo, SqlReferenceAnalysis, SqlTableReference, SqlTextSpan } from "@/types/database";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
|
|
@ -180,9 +127,7 @@ const completionTranslations = computed(() => ({
|
|||
numericLiteral: t("editor.completion.numericLiteral"),
|
||||
booleanValue: t("editor.completion.booleanValue"),
|
||||
starExpansionColumns: t("editor.completion.starExpansionColumns"),
|
||||
functionDescriptions: Object.fromEntries(
|
||||
SQL_FUNCTION_NAMES.map((name) => [name, t(`editor.completion.functionDescriptions.${name}`)]),
|
||||
) as Record<string, string>,
|
||||
functionDescriptions: Object.fromEntries(SQL_FUNCTION_NAMES.map((name) => [name, t(`editor.completion.functionDescriptions.${name}`)])) as Record<string, string>,
|
||||
}));
|
||||
const MAX_COMPLETION_TABLES = 200;
|
||||
const liveFontSize = ref(settingsStore.editorSettings.fontSize);
|
||||
|
|
@ -196,9 +141,7 @@ const executableSql = ref("");
|
|||
const hasSelectedSql = computed(() => selectedSql.value.trim().length > 0);
|
||||
const canCopySelectedSql = computed(() => selectedSql.value.length > 0);
|
||||
const canExecuteContextSql = computed(() => executableSql.value.trim().length > 0);
|
||||
const executeContextMenuLabel = computed(() =>
|
||||
t(hasSelectedSql.value ? "editor.contextMenu.executeSelection" : "editor.contextMenu.executeCurrent"),
|
||||
);
|
||||
const executeContextMenuLabel = computed(() => t(hasSelectedSql.value ? "editor.contextMenu.executeSelection" : "editor.contextMenu.executeCurrent"));
|
||||
|
||||
interface EditorGestureEvent extends Event {
|
||||
scale?: number;
|
||||
|
|
@ -366,11 +309,7 @@ function selectAllSqlFromContextMenu() {
|
|||
focusEditor();
|
||||
}
|
||||
|
||||
function selectSqlLineFromGutter(
|
||||
currentView: EditorViewType,
|
||||
line: { from: number; to: number },
|
||||
event: Event,
|
||||
): boolean {
|
||||
function selectSqlLineFromGutter(currentView: EditorViewType, line: { from: number; to: number }, event: Event): boolean {
|
||||
if (!(event instanceof MouseEvent) || event.button !== 0) return false;
|
||||
event.preventDefault();
|
||||
currentView.dispatch({
|
||||
|
|
@ -480,12 +419,7 @@ function completionCacheKey(table: { name: string; schema?: string | null }) {
|
|||
async function ensureColumnsForTable(table: { name: string; schema?: string | null }) {
|
||||
const cacheKey = completionCacheKey(table);
|
||||
if (cachedColumnsByTable.has(cacheKey) || !props.connectionId || props.database == null) return;
|
||||
const columns = await connectionStore.listCompletionColumns(
|
||||
props.connectionId,
|
||||
props.database,
|
||||
table.name,
|
||||
table.schema ?? props.schema,
|
||||
);
|
||||
const columns = await connectionStore.listCompletionColumns(props.connectionId, props.database, table.name, table.schema ?? props.schema);
|
||||
if (columns.length === 0) return;
|
||||
cachedColumnsByTable.set(cacheKey, columns);
|
||||
}
|
||||
|
|
@ -557,8 +491,7 @@ function createSignatureDom(signature: ReturnType<typeof getSqlFunctionSignature
|
|||
signatureNode.appendChild(comma);
|
||||
}
|
||||
const parameterNode = document.createElement("span");
|
||||
parameterNode.className =
|
||||
index === signature.activeParameter ? "font-semibold text-foreground" : "text-muted-foreground";
|
||||
parameterNode.className = index === signature.activeParameter ? "font-semibold text-foreground" : "text-muted-foreground";
|
||||
parameterNode.textContent = parameter;
|
||||
signatureNode.appendChild(parameterNode);
|
||||
});
|
||||
|
|
@ -586,24 +519,12 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number)
|
|||
|
||||
try {
|
||||
if (cachedTables.length === 0) {
|
||||
cachedTables = await connectionStore.listCompletionTables(
|
||||
props.connectionId,
|
||||
props.database,
|
||||
name,
|
||||
MAX_COMPLETION_TABLES,
|
||||
props.schema,
|
||||
);
|
||||
cachedTables = await connectionStore.listCompletionTables(props.connectionId, props.database, name, MAX_COMPLETION_TABLES, props.schema);
|
||||
}
|
||||
|
||||
let table = matchTable(identifier, cachedTables) ?? matchTable(name, cachedTables);
|
||||
if (!table) {
|
||||
const hoverTables = await connectionStore.listCompletionTables(
|
||||
props.connectionId,
|
||||
props.database,
|
||||
name,
|
||||
MAX_COMPLETION_TABLES,
|
||||
props.schema,
|
||||
);
|
||||
const hoverTables = await connectionStore.listCompletionTables(props.connectionId, props.database, name, MAX_COMPLETION_TABLES, props.schema);
|
||||
cachedTables = [...cachedTables, ...hoverTables];
|
||||
table = matchTable(identifier, hoverTables) ?? matchTable(name, hoverTables);
|
||||
}
|
||||
|
|
@ -618,12 +539,7 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number)
|
|||
}
|
||||
|
||||
const context = getSqlCompletionContext(sql, pos);
|
||||
const candidates = qualifier
|
||||
? context.referencedTables.filter(
|
||||
(rt) =>
|
||||
rt.alias?.toLowerCase() === qualifier.toLowerCase() || rt.name.toLowerCase() === qualifier.toLowerCase(),
|
||||
)
|
||||
: context.referencedTables;
|
||||
const candidates = qualifier ? context.referencedTables.filter((rt) => rt.alias?.toLowerCase() === qualifier.toLowerCase() || rt.name.toLowerCase() === qualifier.toLowerCase()) : context.referencedTables;
|
||||
|
||||
for (const refTable of candidates) {
|
||||
await ensureColumnsForTable(refTable);
|
||||
|
|
@ -634,10 +550,7 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number)
|
|||
pos: range.from,
|
||||
end: range.to,
|
||||
create: () => ({
|
||||
dom: createHoverDom(column.name, column.dataType || "column", [
|
||||
column.schema ? `${column.schema}.${column.table}` : column.table,
|
||||
...(column.comment?.trim() ? [column.comment.trim()] : []),
|
||||
]),
|
||||
dom: createHoverDom(column.name, column.dataType || "column", [column.schema ? `${column.schema}.${column.table}` : column.table, ...(column.comment?.trim() ? [column.comment.trim()] : [])]),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
@ -719,13 +632,7 @@ async function enrichSemanticDiagnosticTables(tables: SqlTableReference[]) {
|
|||
continue;
|
||||
}
|
||||
try {
|
||||
const matches = await connectionStore.listCompletionTables(
|
||||
props.connectionId,
|
||||
props.database,
|
||||
table.name,
|
||||
MAX_COMPLETION_TABLES,
|
||||
props.schema,
|
||||
);
|
||||
const matches = await connectionStore.listCompletionTables(props.connectionId, props.database, table.name, MAX_COMPLETION_TABLES, props.schema);
|
||||
cachedTables = [...cachedTables, ...matches];
|
||||
const match = matches.find((item) => item.name.toLowerCase() === table.name.toLowerCase());
|
||||
enriched.push(match?.schema ? { ...table, schema: match.schema } : table);
|
||||
|
|
@ -753,9 +660,7 @@ async function refreshSemanticDiagnostics() {
|
|||
setSemanticDiagnostics([]);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!shouldRunSqlSemanticDiagnostics(sql, currentView.state.selection.main.head, { databaseType: props.databaseType })
|
||||
) {
|
||||
if (!shouldRunSqlSemanticDiagnostics(sql, currentView.state.selection.main.head, { databaseType: props.databaseType })) {
|
||||
scheduleSemanticDiagnostics(1200);
|
||||
return;
|
||||
}
|
||||
|
|
@ -809,24 +714,14 @@ async function formatCurrentSql() {
|
|||
if (!source.trim()) return;
|
||||
|
||||
try {
|
||||
const formatted = await formatSqlText(
|
||||
source,
|
||||
props.formatDialect ?? props.dialect ?? "generic",
|
||||
settingsStore.editorSettings.sqlFormatter,
|
||||
);
|
||||
if (
|
||||
view.value !== currentView ||
|
||||
currentView.state !== originalState ||
|
||||
currentView.state.sliceDoc(from, to) !== source
|
||||
) {
|
||||
const formatted = await formatSqlText(source, props.formatDialect ?? props.dialect ?? "generic", settingsStore.editorSettings.sqlFormatter);
|
||||
if (view.value !== currentView || currentView.state !== originalState || currentView.state.sliceDoc(from, to) !== source) {
|
||||
return;
|
||||
}
|
||||
if (formatted === source) return;
|
||||
currentView.dispatch({
|
||||
changes: { from, to, insert: formatted },
|
||||
selection: formatsSelection
|
||||
? { anchor: from, head: from + formatted.length }
|
||||
: { anchor: from + formatted.length },
|
||||
selection: formatsSelection ? { anchor: from, head: from + formatted.length } : { anchor: from + formatted.length },
|
||||
});
|
||||
} catch (e: any) {
|
||||
emit("formatError", String(e?.message || e));
|
||||
|
|
@ -834,21 +729,14 @@ async function formatCurrentSql() {
|
|||
}
|
||||
|
||||
function droppedTableReference(event: DragEvent) {
|
||||
return (
|
||||
activeTableReferencePayloadValue() ??
|
||||
parseTableReferencePayload(event.dataTransfer?.getData(DBX_TABLE_REFERENCE_MIME))
|
||||
);
|
||||
return activeTableReferencePayloadValue() ?? parseTableReferencePayload(event.dataTransfer?.getData(DBX_TABLE_REFERENCE_MIME));
|
||||
}
|
||||
|
||||
function hasDroppedTableReference(event: DragEvent) {
|
||||
return !!activeTableReferencePayloadValue() || hasTableReferencePayloadType(event.dataTransfer?.types);
|
||||
}
|
||||
|
||||
function insertTableReferencePayload(
|
||||
currentView: EditorViewType,
|
||||
payload: QueryEditorTableReferencePayload,
|
||||
coords?: { clientX: number; clientY: number },
|
||||
): boolean {
|
||||
function insertTableReferencePayload(currentView: EditorViewType, payload: QueryEditorTableReferencePayload, coords?: { clientX: number; clientY: number }): boolean {
|
||||
if (props.readOnly) return false;
|
||||
const insertText = tableReferenceInsertText(payload, props.databaseType);
|
||||
const dropPos = coords ? currentView.posAtCoords({ x: coords.clientX, y: coords.clientY }) : null;
|
||||
|
|
@ -951,11 +839,7 @@ function completionOptionForItem(item: QueryCompletionItem) {
|
|||
};
|
||||
}
|
||||
|
||||
async function provideElasticsearchCompletions(
|
||||
currentState: import("@codemirror/state").EditorState,
|
||||
position: number,
|
||||
explicit: boolean,
|
||||
) {
|
||||
async function provideElasticsearchCompletions(currentState: import("@codemirror/state").EditorState, position: number, explicit: boolean) {
|
||||
if (!props.connectionId) return null;
|
||||
const epoch = ++completionEpoch;
|
||||
const fullDoc = currentState.doc.toString();
|
||||
|
|
@ -976,11 +860,7 @@ async function provideElasticsearchCompletions(
|
|||
return buildCompletionResult(items, completionContext.from, getElasticsearchCompletionResultValidFor());
|
||||
}
|
||||
|
||||
async function provideSqlCompletions(
|
||||
currentState: import("@codemirror/state").EditorState,
|
||||
position: number,
|
||||
explicit: boolean,
|
||||
) {
|
||||
async function provideSqlCompletions(currentState: import("@codemirror/state").EditorState, position: number, explicit: boolean) {
|
||||
if (!props.connectionId) return null;
|
||||
const fullDoc = currentState.doc.toString();
|
||||
if (props.databaseType === "elasticsearch") {
|
||||
|
|
@ -1008,21 +888,11 @@ async function provideSqlCompletions(
|
|||
dialect: props.dialect,
|
||||
databaseType: props.databaseType,
|
||||
});
|
||||
return buildCompletionResult(
|
||||
items,
|
||||
position - completionContext.prefix.length,
|
||||
getSqlCompletionResultValidFor(fullDoc, position),
|
||||
);
|
||||
return buildCompletionResult(items, position - completionContext.prefix.length, getSqlCompletionResultValidFor(fullDoc, position));
|
||||
}
|
||||
|
||||
const needsAsyncData =
|
||||
completionContext.suggestTables ||
|
||||
completionContext.suggestRoutines ||
|
||||
completionContext.exclusiveRoutineSuggestions ||
|
||||
!!completionContext.qualifier ||
|
||||
!!completionContext.insertTable ||
|
||||
completionContext.exclusiveColumnSuggestions ||
|
||||
completionContext.referencedTables.length > 0;
|
||||
completionContext.suggestTables || completionContext.suggestRoutines || completionContext.exclusiveRoutineSuggestions || !!completionContext.qualifier || !!completionContext.insertTable || completionContext.exclusiveColumnSuggestions || completionContext.referencedTables.length > 0;
|
||||
|
||||
if (!needsAsyncData) {
|
||||
const items = buildSqlCompletionItemsFromContext(completionContext, {
|
||||
|
|
@ -1035,11 +905,7 @@ async function provideSqlCompletions(
|
|||
dialect: props.dialect,
|
||||
databaseType: props.databaseType,
|
||||
});
|
||||
return buildCompletionResult(
|
||||
items,
|
||||
position - completionContext.prefix.length,
|
||||
getSqlCompletionResultValidFor(fullDoc, position),
|
||||
);
|
||||
return buildCompletionResult(items, position - completionContext.prefix.length, getSqlCompletionResultValidFor(fullDoc, position));
|
||||
}
|
||||
|
||||
const localResult = buildLocalSqlCompletionResult(completionContext, fullDoc, position);
|
||||
|
|
@ -1081,71 +947,26 @@ async function provideSqlCompletions(
|
|||
}
|
||||
}
|
||||
|
||||
function buildLocalSqlCompletionResult(
|
||||
completionContext: ReturnType<typeof getSqlCompletionContext>,
|
||||
fullDoc: string,
|
||||
position: number,
|
||||
) {
|
||||
function buildLocalSqlCompletionResult(completionContext: ReturnType<typeof getSqlCompletionContext>, fullDoc: string, position: number) {
|
||||
if (!props.connectionId || props.database == null) return null;
|
||||
const shouldLoadTables =
|
||||
completionContext.suggestTables ||
|
||||
(!!completionContext.qualifier && !isReferencedTableQualifier(completionContext));
|
||||
const tableLookupSchema =
|
||||
completionContext.qualifier && completionContext.suggestTables ? completionContext.qualifier : props.schema;
|
||||
const tableLookupFilter =
|
||||
completionContext.qualifier && completionContext.suggestTables
|
||||
? completionContext.prefix
|
||||
: completionContext.qualifier || completionContext.prefix;
|
||||
const tables = shouldLoadTables
|
||||
? connectionStore.lookupLocalCompletionTables(
|
||||
props.connectionId,
|
||||
props.database,
|
||||
tableLookupFilter,
|
||||
MAX_COMPLETION_TABLES,
|
||||
tableLookupSchema,
|
||||
)
|
||||
: cachedTables;
|
||||
const shouldLoadTables = completionContext.suggestTables || (!!completionContext.qualifier && !isReferencedTableQualifier(completionContext));
|
||||
const tableLookupSchema = completionContext.qualifier && completionContext.suggestTables ? completionContext.qualifier : props.schema;
|
||||
const tableLookupFilter = completionContext.qualifier && completionContext.suggestTables ? completionContext.prefix : completionContext.qualifier || completionContext.prefix;
|
||||
const tables = shouldLoadTables ? connectionStore.lookupLocalCompletionTables(props.connectionId, props.database, tableLookupFilter, MAX_COMPLETION_TABLES, tableLookupSchema) : cachedTables;
|
||||
|
||||
const shouldLoadObjects =
|
||||
completionContext.suggestRoutines ||
|
||||
completionContext.exclusiveRoutineSuggestions ||
|
||||
(!!completionContext.qualifier && !completionContext.exclusiveColumnSuggestions);
|
||||
const shouldLoadObjects = completionContext.suggestRoutines || completionContext.exclusiveRoutineSuggestions || (!!completionContext.qualifier && !completionContext.exclusiveColumnSuggestions);
|
||||
const completionObjects = shouldLoadObjects
|
||||
? connectionStore.lookupLocalCompletionObjects(
|
||||
props.connectionId,
|
||||
props.database,
|
||||
completionContext.qualifier || completionContext.prefix,
|
||||
MAX_COMPLETION_TABLES,
|
||||
completionContext.qualifier && !completionContext.exclusiveColumnSuggestions
|
||||
? completionContext.qualifier
|
||||
: props.schema,
|
||||
)
|
||||
? connectionStore.lookupLocalCompletionObjects(props.connectionId, props.database, completionContext.qualifier || completionContext.prefix, MAX_COMPLETION_TABLES, completionContext.qualifier && !completionContext.exclusiveColumnSuggestions ? completionContext.qualifier : props.schema)
|
||||
: cachedCompletionObjects;
|
||||
|
||||
const schemaNames =
|
||||
completionContext.suggestTables && !completionContext.qualifier && !completionContext.insertTable
|
||||
? connectionStore.lookupLocalCompletionSchemas(
|
||||
props.connectionId,
|
||||
props.database,
|
||||
completionContext.prefix,
|
||||
MAX_COMPLETION_TABLES,
|
||||
)
|
||||
: [];
|
||||
const schemaNames = completionContext.suggestTables && !completionContext.qualifier && !completionContext.insertTable ? connectionStore.lookupLocalCompletionSchemas(props.connectionId, props.database, completionContext.prefix, MAX_COMPLETION_TABLES) : [];
|
||||
|
||||
const columnsByTable = new Map<string, SqlCompletionColumn[]>();
|
||||
if (completionContext.insertTable) {
|
||||
const insertSchema = completionContext.insertSchema ?? props.schema;
|
||||
const insertColumns = connectionStore.lookupLocalCompletionColumns(
|
||||
props.connectionId,
|
||||
props.database,
|
||||
completionContext.insertTable,
|
||||
insertSchema,
|
||||
);
|
||||
const insertColumns = connectionStore.lookupLocalCompletionColumns(props.connectionId, props.database, completionContext.insertTable, insertSchema);
|
||||
if (insertColumns.length > 0) {
|
||||
columnsByTable.set(
|
||||
insertSchema ? `${insertSchema}.${completionContext.insertTable}` : completionContext.insertTable,
|
||||
insertColumns,
|
||||
);
|
||||
columnsByTable.set(insertSchema ? `${insertSchema}.${completionContext.insertTable}` : completionContext.insertTable, insertColumns);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1165,26 +986,13 @@ function buildLocalSqlCompletionResult(
|
|||
columnsByTable.set(cacheKey, cached);
|
||||
continue;
|
||||
}
|
||||
const localColumns = connectionStore.lookupLocalCompletionColumns(
|
||||
props.connectionId,
|
||||
props.database,
|
||||
refTable.name,
|
||||
refTable.schema ?? props.schema,
|
||||
);
|
||||
const localColumns = connectionStore.lookupLocalCompletionColumns(props.connectionId, props.database, refTable.name, refTable.schema ?? props.schema);
|
||||
if (localColumns.length > 0) {
|
||||
columnsByTable.set(cacheKey, localColumns);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
tables.length === 0 &&
|
||||
completionObjects.length === 0 &&
|
||||
schemaNames.length === 0 &&
|
||||
columnsByTable.size === 0 &&
|
||||
(completionContext.exclusiveTableSuggestions ||
|
||||
completionContext.exclusiveColumnSuggestions ||
|
||||
completionContext.exclusiveRoutineSuggestions)
|
||||
) {
|
||||
if (tables.length === 0 && completionObjects.length === 0 && schemaNames.length === 0 && columnsByTable.size === 0 && (completionContext.exclusiveTableSuggestions || completionContext.exclusiveColumnSuggestions || completionContext.exclusiveRoutineSuggestions)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -1200,41 +1008,23 @@ function buildLocalSqlCompletionResult(
|
|||
databaseType: props.databaseType,
|
||||
});
|
||||
|
||||
return buildCompletionResult(
|
||||
items,
|
||||
position - completionContext.prefix.length,
|
||||
getSqlCompletionResultValidFor(fullDoc, position),
|
||||
);
|
||||
return buildCompletionResult(items, position - completionContext.prefix.length, getSqlCompletionResultValidFor(fullDoc, position));
|
||||
}
|
||||
|
||||
function scheduleCompletionMetadataRefresh(completionContext: ReturnType<typeof getSqlCompletionContext>) {
|
||||
if (!props.connectionId || props.database == null) return;
|
||||
const connectionId = props.connectionId;
|
||||
const database = props.database;
|
||||
const schema =
|
||||
completionContext.qualifier && completionContext.suggestTables ? completionContext.qualifier : props.schema;
|
||||
if (
|
||||
completionContext.suggestTables ||
|
||||
(!!completionContext.qualifier && !isReferencedTableQualifier(completionContext))
|
||||
) {
|
||||
const schema = completionContext.qualifier && completionContext.suggestTables ? completionContext.qualifier : props.schema;
|
||||
if (completionContext.suggestTables || (!!completionContext.qualifier && !isReferencedTableQualifier(completionContext))) {
|
||||
void connectionStore
|
||||
.refreshCompletionTables(
|
||||
connectionId,
|
||||
database,
|
||||
completionContext.qualifier && !schema ? completionContext.qualifier : completionContext.prefix,
|
||||
MAX_COMPLETION_TABLES,
|
||||
schema,
|
||||
)
|
||||
.refreshCompletionTables(connectionId, database, completionContext.qualifier && !schema ? completionContext.qualifier : completionContext.prefix, MAX_COMPLETION_TABLES, schema)
|
||||
.then((tables) => {
|
||||
cachedTables = mergeCompletionTables(cachedTables, tables);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
if (
|
||||
completionContext.suggestRoutines ||
|
||||
completionContext.exclusiveRoutineSuggestions ||
|
||||
(!!completionContext.qualifier && !completionContext.exclusiveColumnSuggestions)
|
||||
) {
|
||||
if (completionContext.suggestRoutines || completionContext.exclusiveRoutineSuggestions || (!!completionContext.qualifier && !completionContext.exclusiveColumnSuggestions)) {
|
||||
void connectionStore
|
||||
.refreshCompletionObjects(connectionId, database, completionContext.prefix, MAX_COMPLETION_TABLES, props.schema)
|
||||
.then((objects) => {
|
||||
|
|
@ -1268,10 +1058,7 @@ function scheduleCompletionMetadataRefresh(completionContext: ReturnType<typeof
|
|||
}
|
||||
}
|
||||
|
||||
function mergeCompletionTables(
|
||||
existing: Array<{ name: string; schema?: string; type?: "table" | "view" }>,
|
||||
incoming: Array<{ name: string; schema?: string; type?: "table" | "view" }>,
|
||||
) {
|
||||
function mergeCompletionTables(existing: Array<{ name: string; schema?: string; type?: "table" | "view" }>, incoming: Array<{ name: string; schema?: string; type?: "table" | "view" }>) {
|
||||
const merged = [...existing];
|
||||
const seen = new Set(existing.map((table) => `${table.schema ?? ""}.${table.name}`.toLowerCase()));
|
||||
for (const table of incoming) {
|
||||
|
|
@ -1283,28 +1070,16 @@ function mergeCompletionTables(
|
|||
return merged;
|
||||
}
|
||||
|
||||
async function performAsyncCompletionWithResult(
|
||||
epoch: number,
|
||||
completionContext: ReturnType<typeof getSqlCompletionContext>,
|
||||
fullDoc: string,
|
||||
position: number,
|
||||
) {
|
||||
async function performAsyncCompletionWithResult(epoch: number, completionContext: ReturnType<typeof getSqlCompletionContext>, fullDoc: string, position: number) {
|
||||
// Handle INSERT column list: fetch columns for the target table
|
||||
let insertColumnsByTable = new Map<string, SqlCompletionColumn[]>();
|
||||
if (completionContext.insertTable) {
|
||||
try {
|
||||
const insertCols = await connectionStore.listCompletionColumns(
|
||||
props.connectionId!,
|
||||
props.database!,
|
||||
completionContext.insertTable,
|
||||
completionContext.insertSchema ?? props.schema,
|
||||
);
|
||||
const insertCols = await connectionStore.listCompletionColumns(props.connectionId!, props.database!, completionContext.insertTable, completionContext.insertSchema ?? props.schema);
|
||||
if (epoch !== completionEpoch) return null;
|
||||
if (insertCols.length > 0) {
|
||||
const insertSchema = completionContext.insertSchema ?? props.schema;
|
||||
const insertKey = insertSchema
|
||||
? `${insertSchema}.${completionContext.insertTable}`
|
||||
: completionContext.insertTable;
|
||||
const insertKey = insertSchema ? `${insertSchema}.${completionContext.insertTable}` : completionContext.insertTable;
|
||||
insertColumnsByTable.set(insertKey, insertCols);
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -1312,43 +1087,16 @@ async function performAsyncCompletionWithResult(
|
|||
}
|
||||
}
|
||||
|
||||
const shouldLoadTables =
|
||||
completionContext.suggestTables ||
|
||||
(!!completionContext.qualifier && !isReferencedTableQualifier(completionContext));
|
||||
let tables = shouldLoadTables
|
||||
? await connectionStore.listCompletionTables(
|
||||
props.connectionId!,
|
||||
props.database!,
|
||||
completionContext.qualifier || completionContext.prefix,
|
||||
MAX_COMPLETION_TABLES,
|
||||
props.schema,
|
||||
)
|
||||
: cachedTables;
|
||||
const shouldLoadTables = completionContext.suggestTables || (!!completionContext.qualifier && !isReferencedTableQualifier(completionContext));
|
||||
let tables = shouldLoadTables ? await connectionStore.listCompletionTables(props.connectionId!, props.database!, completionContext.qualifier || completionContext.prefix, MAX_COMPLETION_TABLES, props.schema) : cachedTables;
|
||||
if (epoch !== completionEpoch) return null;
|
||||
|
||||
const shouldLoadObjects =
|
||||
completionContext.suggestRoutines ||
|
||||
completionContext.exclusiveRoutineSuggestions ||
|
||||
(!!completionContext.qualifier && !completionContext.exclusiveColumnSuggestions);
|
||||
let completionObjects = shouldLoadObjects
|
||||
? await connectionStore.listCompletionObjects(
|
||||
props.connectionId!,
|
||||
props.database!,
|
||||
completionContext.qualifier || completionContext.prefix,
|
||||
MAX_COMPLETION_TABLES,
|
||||
props.schema,
|
||||
)
|
||||
: cachedCompletionObjects;
|
||||
const shouldLoadObjects = completionContext.suggestRoutines || completionContext.exclusiveRoutineSuggestions || (!!completionContext.qualifier && !completionContext.exclusiveColumnSuggestions);
|
||||
let completionObjects = shouldLoadObjects ? await connectionStore.listCompletionObjects(props.connectionId!, props.database!, completionContext.qualifier || completionContext.prefix, MAX_COMPLETION_TABLES, props.schema) : cachedCompletionObjects;
|
||||
if (epoch !== completionEpoch) return null;
|
||||
|
||||
if (completionContext.qualifier && completionObjects.length === 0) {
|
||||
const schemaObjects = await connectionStore.listCompletionObjects(
|
||||
props.connectionId!,
|
||||
props.database!,
|
||||
completionContext.prefix,
|
||||
MAX_COMPLETION_TABLES,
|
||||
completionContext.qualifier,
|
||||
);
|
||||
const schemaObjects = await connectionStore.listCompletionObjects(props.connectionId!, props.database!, completionContext.prefix, MAX_COMPLETION_TABLES, completionContext.qualifier);
|
||||
if (schemaObjects.length > 0) {
|
||||
completionObjects = schemaObjects;
|
||||
}
|
||||
|
|
@ -1369,18 +1117,8 @@ async function performAsyncCompletionWithResult(
|
|||
|
||||
// If qualifier didn't match any table names, try it as a schema name
|
||||
let qualifierIsSchema = false;
|
||||
if (
|
||||
completionContext.qualifier &&
|
||||
tables.length === 0 &&
|
||||
(completionContext.suggestTables || completionContext.exclusiveColumnSuggestions)
|
||||
) {
|
||||
const schemaTables = await connectionStore.listCompletionTables(
|
||||
props.connectionId!,
|
||||
props.database!,
|
||||
completionContext.prefix,
|
||||
MAX_COMPLETION_TABLES,
|
||||
completionContext.qualifier,
|
||||
);
|
||||
if (completionContext.qualifier && tables.length === 0 && (completionContext.suggestTables || completionContext.exclusiveColumnSuggestions)) {
|
||||
const schemaTables = await connectionStore.listCompletionTables(props.connectionId!, props.database!, completionContext.prefix, MAX_COMPLETION_TABLES, completionContext.qualifier);
|
||||
if (schemaTables.length > 0) {
|
||||
tables = schemaTables;
|
||||
qualifierIsSchema = true;
|
||||
|
|
@ -1400,11 +1138,7 @@ async function performAsyncCompletionWithResult(
|
|||
});
|
||||
const unresolvedRefs = refs.filter((rt) => !rt.schema && !rt.columns);
|
||||
if (unresolvedRefs.length > 0) {
|
||||
const lookupGroups = await Promise.all(
|
||||
unresolvedRefs.map((rt) =>
|
||||
connectionStore.listCompletionTables(props.connectionId!, props.database!, rt.name, 20, props.schema),
|
||||
),
|
||||
);
|
||||
const lookupGroups = await Promise.all(unresolvedRefs.map((rt) => connectionStore.listCompletionTables(props.connectionId!, props.database!, rt.name, 20, props.schema)));
|
||||
if (epoch !== completionEpoch) return null;
|
||||
const lookupTables = lookupGroups.flat();
|
||||
refs = refs.map((rt) => {
|
||||
|
|
@ -1437,12 +1171,7 @@ async function performAsyncCompletionWithResult(
|
|||
const cacheKey = refTable.schema ? `${refTable.schema}.${refTable.name}` : refTable.name;
|
||||
if (cachedColumnsByTable.has(cacheKey)) return;
|
||||
try {
|
||||
const columns = await connectionStore.listCompletionColumns(
|
||||
props.connectionId!,
|
||||
props.database!,
|
||||
refTable.name,
|
||||
refTable.schema ?? props.schema,
|
||||
);
|
||||
const columns = await connectionStore.listCompletionColumns(props.connectionId!, props.database!, refTable.name, refTable.schema ?? props.schema);
|
||||
if (epoch !== completionEpoch) return;
|
||||
if (columns.length === 0) return;
|
||||
cachedColumnsByTable.set(cacheKey, columns);
|
||||
|
|
@ -1518,28 +1247,18 @@ async function performAsyncCompletionWithResult(
|
|||
databaseType: props.databaseType,
|
||||
});
|
||||
|
||||
return buildCompletionResult(
|
||||
items,
|
||||
position - completionContext.prefix.length,
|
||||
getSqlCompletionResultValidFor(fullDoc, position),
|
||||
);
|
||||
return buildCompletionResult(items, position - completionContext.prefix.length, getSqlCompletionResultValidFor(fullDoc, position));
|
||||
}
|
||||
|
||||
function isReferencedTableQualifier(completionContext: ReturnType<typeof getSqlCompletionContext>): boolean {
|
||||
if (!completionContext.qualifier) return false;
|
||||
const qualifier = completionContext.qualifier.toLowerCase();
|
||||
return completionContext.referencedTables.some(
|
||||
(table) => table.alias?.toLowerCase() === qualifier || table.name.toLowerCase() === qualifier,
|
||||
);
|
||||
return completionContext.referencedTables.some((table) => table.alias?.toLowerCase() === qualifier || table.name.toLowerCase() === qualifier);
|
||||
}
|
||||
|
||||
function mergeCompletionObjects(existing: SqlCompletionObject[], incoming: SqlCompletionObject[]) {
|
||||
const merged = [...existing];
|
||||
const seen = new Set(
|
||||
existing.map((object) =>
|
||||
`${object.type}:${object.schema ?? ""}:${object.name}:${object.parentName ?? ""}`.toLowerCase(),
|
||||
),
|
||||
);
|
||||
const seen = new Set(existing.map((object) => `${object.type}:${object.schema ?? ""}:${object.name}:${object.parentName ?? ""}`.toLowerCase()));
|
||||
for (const object of incoming) {
|
||||
const key = `${object.type}:${object.schema ?? ""}:${object.name}:${object.parentName ?? ""}`.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
|
|
@ -1560,46 +1279,14 @@ onMounted(async () => {
|
|||
if (!editorRef.value) return;
|
||||
|
||||
const [
|
||||
{
|
||||
EditorView,
|
||||
keymap,
|
||||
rectangularSelection,
|
||||
hoverTooltip,
|
||||
showTooltip,
|
||||
Decoration,
|
||||
tooltips,
|
||||
lineNumbers,
|
||||
highlightActiveLineGutter,
|
||||
highlightSpecialChars,
|
||||
drawSelection,
|
||||
dropCursor,
|
||||
crosshairCursor,
|
||||
ViewPlugin,
|
||||
},
|
||||
{ EditorView, keymap, rectangularSelection, hoverTooltip, showTooltip, Decoration, tooltips, lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, dropCursor, crosshairCursor, ViewPlugin },
|
||||
{ EditorState, Compartment, Prec, StateEffect, StateField },
|
||||
{ sql, MSSQL, MySQL, PostgreSQL, SQLDialect },
|
||||
{
|
||||
autocompletion,
|
||||
startCompletion,
|
||||
acceptCompletion,
|
||||
closeBrackets,
|
||||
closeBracketsKeymap,
|
||||
snippetCompletion,
|
||||
completionStatus,
|
||||
completionKeymap,
|
||||
},
|
||||
{ autocompletion, startCompletion, acceptCompletion, closeBrackets, closeBracketsKeymap, snippetCompletion, completionStatus, completionKeymap },
|
||||
{ indentMore, insertNewlineKeepIndent, history, defaultKeymap, historyKeymap },
|
||||
{ bracketMatching, foldGutter, indentOnInput, syntaxHighlighting, defaultHighlightStyle, foldKeymap },
|
||||
{ searchKeymap },
|
||||
] = await Promise.all([
|
||||
import("@codemirror/view"),
|
||||
import("@codemirror/state"),
|
||||
import("@codemirror/lang-sql"),
|
||||
import("@codemirror/autocomplete"),
|
||||
import("@codemirror/commands"),
|
||||
import("@codemirror/language"),
|
||||
import("@codemirror/search"),
|
||||
]);
|
||||
] = await Promise.all([import("@codemirror/view"), import("@codemirror/state"), import("@codemirror/lang-sql"), import("@codemirror/autocomplete"), import("@codemirror/commands"), import("@codemirror/language"), import("@codemirror/search")]);
|
||||
editorViewModule = { EditorView, keymap, rectangularSelection } as typeof import("@codemirror/view");
|
||||
codeMirrorPrec = Prec;
|
||||
codeMirrorSnippetCompletion = snippetCompletion;
|
||||
|
|
@ -1649,8 +1336,7 @@ onMounted(async () => {
|
|||
const field = StateField.define({
|
||||
create: buildDecorations,
|
||||
update(value, transaction) {
|
||||
const diagnosticsChanged =
|
||||
!!diagnosticEffect && transaction.effects.some((effect) => effect.is(diagnosticEffect));
|
||||
const diagnosticsChanged = !!diagnosticEffect && transaction.effects.some((effect) => effect.is(diagnosticEffect));
|
||||
return transaction.docChanged || diagnosticsChanged ? buildDecorations(transaction.state) : value;
|
||||
},
|
||||
provide: (field) => EditorView.decorations.from(field),
|
||||
|
|
@ -1674,22 +1360,17 @@ onMounted(async () => {
|
|||
buildSqlCompletionExtension = () =>
|
||||
autocompletion({
|
||||
activateOnTyping: true,
|
||||
override: [
|
||||
async (context: CompletionContext) => provideSqlCompletions(context.state, context.pos, context.explicit),
|
||||
],
|
||||
override: [async (context: CompletionContext) => provideSqlCompletions(context.state, context.pos, context.explicit)],
|
||||
});
|
||||
|
||||
const baseDialect = props.dialect === "postgres" ? PostgreSQL : props.dialect === "sqlserver" ? MSSQL : MySQL;
|
||||
const extraKeywords =
|
||||
"PIVOT UNPIVOT EXCLUDE REPLACE QUALIFY ASOF POSITIONAL ANTI SEMI SAMPLE TABLESAMPLE STRUCT MAP LIST ARRAY LAMBDA UNNEST LATERAL FILTER RECURSIVE SUMMARIZE PRAGMA READ_CSV READ_PARQUET READ_JSON DESCRIBE SHOW COPY EXPORT IMPORT";
|
||||
const extraKeywords = "PIVOT UNPIVOT EXCLUDE REPLACE QUALIFY ASOF POSITIONAL ANTI SEMI SAMPLE TABLESAMPLE STRUCT MAP LIST ARRAY LAMBDA UNNEST LATERAL FILTER RECURSIVE SUMMARIZE PRAGMA READ_CSV READ_PARQUET READ_JSON DESCRIBE SHOW COPY EXPORT IMPORT";
|
||||
|
||||
// PL/pgSQL extension: add procedural language keywords and built-in variables for PostgreSQL function/procedure bodies
|
||||
const isPostgres = props.dialect === "postgres";
|
||||
const plpgsqlKeywords = isPostgres ? "PERFORM" : "";
|
||||
const plpgsqlTypes = isPostgres ? " RECORD JSON JSONB" : "";
|
||||
const plpgsqlBuiltin = isPostgres
|
||||
? "SQLERRM TG_NAME TG_WHEN TG_LEVEL TG_OP TG_RELID TG_RELNAME TG_TABLE_NAME TG_TABLE_SCHEMA TG_NARGS TG_ARGV"
|
||||
: "";
|
||||
const plpgsqlBuiltin = isPostgres ? "SQLERRM TG_NAME TG_WHEN TG_LEVEL TG_OP TG_RELID TG_RELNAME TG_TABLE_NAME TG_TABLE_SCHEMA TG_NARGS TG_ARGV" : "";
|
||||
|
||||
const dialect = SQLDialect.define({
|
||||
...baseDialect.spec,
|
||||
|
|
@ -1873,22 +1554,13 @@ onMounted(async () => {
|
|||
try {
|
||||
// Ensure table cache is populated
|
||||
if (cachedTables.length === 0) {
|
||||
cachedTables = await connectionStore.listCompletionTables(
|
||||
props.connectionId!,
|
||||
props.database!,
|
||||
identifier,
|
||||
MAX_COMPLETION_TABLES,
|
||||
props.schema,
|
||||
);
|
||||
cachedTables = await connectionStore.listCompletionTables(props.connectionId!, props.database!, identifier, MAX_COMPLETION_TABLES, props.schema);
|
||||
}
|
||||
|
||||
// 1. Check if it's a table name
|
||||
const matchedTable = matchTable(identifier, cachedTables);
|
||||
if (matchedTable) {
|
||||
emit(
|
||||
"clickTable",
|
||||
matchedTable.schema ? `${matchedTable.schema}.${matchedTable.name}` : matchedTable.name,
|
||||
);
|
||||
emit("clickTable", matchedTable.schema ? `${matchedTable.schema}.${matchedTable.name}` : matchedTable.name);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1914,13 +1586,7 @@ onMounted(async () => {
|
|||
return;
|
||||
}
|
||||
// 3. Fetch columns — if qualifier, only check matching table; otherwise check all
|
||||
const tablesToCheck = qualifier
|
||||
? referencedTables.filter(
|
||||
(rt) =>
|
||||
rt.alias?.toLowerCase() === qualifier.toLowerCase() ||
|
||||
rt.name.toLowerCase() === qualifier.toLowerCase(),
|
||||
)
|
||||
: referencedTables;
|
||||
const tablesToCheck = qualifier ? referencedTables.filter((rt) => rt.alias?.toLowerCase() === qualifier.toLowerCase() || rt.name.toLowerCase() === qualifier.toLowerCase()) : referencedTables;
|
||||
|
||||
if (tablesToCheck.length === 0 && qualifier) {
|
||||
return;
|
||||
|
|
@ -1935,12 +1601,7 @@ onMounted(async () => {
|
|||
let cols = cachedColumnsByTable.get(cacheKey);
|
||||
if (!cols) {
|
||||
try {
|
||||
cols = await connectionStore.listCompletionColumns(
|
||||
props.connectionId!,
|
||||
props.database!,
|
||||
refTable.name,
|
||||
refTable.schema ?? props.schema,
|
||||
);
|
||||
cols = await connectionStore.listCompletionColumns(props.connectionId!, props.database!, refTable.name, refTable.schema ?? props.schema);
|
||||
cachedColumnsByTable.set(cacheKey, cols);
|
||||
} catch {
|
||||
continue;
|
||||
|
|
@ -2059,9 +1720,7 @@ watch(
|
|||
function getCurrentCustomThemeColors() {
|
||||
const settings = settingsStore.editorSettings;
|
||||
if (settings.theme !== "custom") return settings.customThemeColors;
|
||||
const activeTheme =
|
||||
settings.customThemes?.find((t: { id: string }) => t.id === settings.activeCustomThemeId) ||
|
||||
settings.customThemes?.[0];
|
||||
const activeTheme = settings.customThemes?.find((t: { id: string }) => t.id === settings.activeCustomThemeId) || settings.customThemes?.[0];
|
||||
return activeTheme?.colors ?? settings.customThemeColors;
|
||||
}
|
||||
|
||||
|
|
@ -2079,11 +1738,7 @@ watch(
|
|||
const themeColors = getCurrentCustomThemeColors();
|
||||
const themeExt = await loadEditorTheme(ss.theme, editorThemeAppearance(), themeColors);
|
||||
view.value.dispatch({
|
||||
effects: [
|
||||
codeMirrorTheme.reconfigure(themeExt),
|
||||
wordWrapComp.reconfigure(props.forceWordWrap || ss.wordWrap ? editorViewModule.EditorView.lineWrapping : []),
|
||||
runKeymapComp.reconfigure(runKeymapExtension(editorViewModule.keymap)),
|
||||
],
|
||||
effects: [codeMirrorTheme.reconfigure(themeExt), wordWrapComp.reconfigure(props.forceWordWrap || ss.wordWrap ? editorViewModule.EditorView.lineWrapping : []), runKeymapComp.reconfigure(runKeymapExtension(editorViewModule.keymap))],
|
||||
});
|
||||
},
|
||||
{ deep: true },
|
||||
|
|
@ -2266,12 +1921,7 @@ defineExpose({ openSearch, openReplace, scrollCursorIntoView });
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="h-full w-full overflow-hidden relative"
|
||||
@gesturestart="onEditorGestureStart"
|
||||
@gesturechange="onEditorGestureChange"
|
||||
@gestureend="onEditorGestureEnd"
|
||||
>
|
||||
<div class="h-full w-full overflow-hidden relative" @gesturestart="onEditorGestureStart" @gesturechange="onEditorGestureChange" @gestureend="onEditorGestureEnd">
|
||||
<CustomContextMenu :items="contextMenuItems" v-slot="{ onContextMenu }">
|
||||
<div
|
||||
ref="editorRef"
|
||||
|
|
|
|||
|
|
@ -47,9 +47,7 @@ const filtered = computed(() => {
|
|||
return false;
|
||||
}
|
||||
if (!q) return true;
|
||||
return [entry.sql, entry.connection_name, entry.database, entry.operation, entry.target]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(q));
|
||||
return [entry.sql, entry.connection_name, entry.database, entry.operation, entry.target].filter(Boolean).some((value) => String(value).toLowerCase().includes(q));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -142,10 +140,7 @@ function detailsRows(entry: HistoryEntry) {
|
|||
[t("history.detail.time"), formatFullTime(entry.executed_at)],
|
||||
[t("history.detail.duration"), `${entry.execution_time_ms}ms`],
|
||||
[t("history.detail.affectedRows"), entry.affected_rows ?? "-"],
|
||||
[
|
||||
t("history.detail.rollback"),
|
||||
canRollbackHistoryEntry(entry) ? t("history.rollbackAvailable") : t("history.rollbackUnavailable"),
|
||||
],
|
||||
[t("history.detail.rollback"), canRollbackHistoryEntry(entry) ? t("history.rollbackAvailable") : t("history.rollbackUnavailable")],
|
||||
[t("history.detail.status"), entry.success ? t("history.success") : t("history.failed")],
|
||||
];
|
||||
if (entry.error) rows.push([t("history.detail.error"), entry.error]);
|
||||
|
|
@ -218,51 +213,23 @@ onMounted(() => store.load());
|
|||
|
||||
<div class="border-b shrink-0">
|
||||
<div class="flex gap-1 overflow-x-auto px-2 pt-2">
|
||||
<button
|
||||
v-for="filter in filters"
|
||||
:key="filter"
|
||||
type="button"
|
||||
class="h-6 shrink-0 rounded border px-2 text-xs"
|
||||
:class="activeFilter === filter ? 'border-primary bg-primary text-primary-foreground' : 'bg-background'"
|
||||
@click="activeFilter = filter"
|
||||
>
|
||||
<button v-for="filter in filters" :key="filter" type="button" class="h-6 shrink-0 rounded border px-2 text-xs" :class="activeFilter === filter ? 'border-primary bg-primary text-primary-foreground' : 'bg-background'" @click="activeFilter = filter">
|
||||
{{ filterLabel(filter) }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative flex items-center px-2 py-1">
|
||||
<Search class="absolute left-3 w-3 h-3 text-muted-foreground pointer-events-none" />
|
||||
<input
|
||||
v-model="searchText"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="flex-1 h-5 text-xs bg-transparent border rounded pl-5 pr-1 outline-none placeholder:text-muted-foreground"
|
||||
:placeholder="t('history.search')"
|
||||
/>
|
||||
<input v-model="searchText" autocapitalize="off" autocorrect="off" spellcheck="false" class="flex-1 h-5 text-xs bg-transparent border rounded pl-5 pr-1 outline-none placeholder:text-muted-foreground" :placeholder="t('history.search')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1">
|
||||
<RecycleScroller
|
||||
v-if="shouldVirtualizeHistory(filtered.length)"
|
||||
class="h-full"
|
||||
:items="filtered"
|
||||
:item-size="HISTORY_ROW_HEIGHT"
|
||||
:buffer="HISTORY_SCROLL_BUFFER"
|
||||
:skip-hover="true"
|
||||
key-field="id"
|
||||
>
|
||||
<RecycleScroller v-if="shouldVirtualizeHistory(filtered.length)" class="h-full" :items="filtered" :item-size="HISTORY_ROW_HEIGHT" :buffer="HISTORY_SCROLL_BUFFER" :skip-hover="true" key-field="id">
|
||||
<template #default="{ item: entry }">
|
||||
<CustomContextMenu :items="getHistoryMenuItems(entry)" v-slot="{ onContextMenu }">
|
||||
<div
|
||||
class="h-[72px] cursor-pointer border-b border-border/50 px-3 py-2 text-xs hover:bg-accent/50"
|
||||
@click="selectedEntry = entry"
|
||||
@contextmenu="onContextMenu"
|
||||
>
|
||||
<div class="h-[72px] cursor-pointer border-b border-border/50 px-3 py-2 text-xs hover:bg-accent/50" @click="selectedEntry = entry" @contextmenu="onContextMenu">
|
||||
<div class="mb-0.5 flex items-center gap-1">
|
||||
<span
|
||||
class="inline-flex h-5 w-9 shrink-0 items-center justify-center rounded border px-1 text-[10px] leading-none text-muted-foreground"
|
||||
>
|
||||
<span class="inline-flex h-5 w-9 shrink-0 items-center justify-center rounded border px-1 text-[10px] leading-none text-muted-foreground">
|
||||
{{ kindShortLabel(entry) }}
|
||||
</span>
|
||||
<span class="truncate font-medium">{{ entryTitle(entry) }}</span>
|
||||
|
|
@ -310,10 +277,7 @@ onMounted(() => store.load());
|
|||
{{ t("history.copy") }}
|
||||
</Button>
|
||||
</div>
|
||||
<pre
|
||||
class="max-h-48 overflow-auto rounded border bg-muted/30 p-3 text-xs"
|
||||
v-html="highlight(selectedEntry.sql)"
|
||||
></pre>
|
||||
<pre class="max-h-48 overflow-auto rounded border bg-muted/30 p-3 text-xs" v-html="highlight(selectedEntry.sql)"></pre>
|
||||
</div>
|
||||
<div v-if="selectedEntry.rollback_sql">
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
|
|
@ -323,10 +287,7 @@ onMounted(() => store.load());
|
|||
{{ t("history.copy") }}
|
||||
</Button>
|
||||
</div>
|
||||
<pre
|
||||
class="max-h-40 overflow-auto rounded border bg-muted/30 p-3 text-xs"
|
||||
v-html="highlight(selectedEntry.rollback_sql || '')"
|
||||
></pre>
|
||||
<pre class="max-h-40 overflow-auto rounded border bg-muted/30 p-3 text-xs" v-html="highlight(selectedEntry.rollback_sql || '')"></pre>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
|
|
@ -335,11 +296,7 @@ onMounted(() => store.load());
|
|||
{{ t("history.analyzeWithAi") }}
|
||||
</Button>
|
||||
<Button variant="outline" @click="selectedEntry && restore(selectedEntry)">{{ t("history.restore") }}</Button>
|
||||
<Button
|
||||
v-if="selectedEntry && canRollbackHistoryEntry(selectedEntry)"
|
||||
:disabled="isRollingBack"
|
||||
@click="rollback(selectedEntry)"
|
||||
>
|
||||
<Button v-if="selectedEntry && canRollbackHistoryEntry(selectedEntry)" :disabled="isRollingBack" @click="rollback(selectedEntry)">
|
||||
<RotateCcw class="h-4 w-4" />
|
||||
{{ isRollingBack ? t("common.loading") : t("history.rollback") }}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -247,13 +247,7 @@ function formatJsonDraft(): boolean {
|
|||
|
||||
async function loadCodeMirrorModules(): Promise<CodeMirrorModules> {
|
||||
if (cmModules) return cmModules;
|
||||
const [view, state, langJson, commands, search] = await Promise.all([
|
||||
import("@codemirror/view"),
|
||||
import("@codemirror/state"),
|
||||
import("@codemirror/lang-json"),
|
||||
import("@codemirror/commands"),
|
||||
import("@codemirror/search"),
|
||||
]);
|
||||
const [view, state, langJson, commands, search] = await Promise.all([import("@codemirror/view"), import("@codemirror/state"), import("@codemirror/lang-json"), import("@codemirror/commands"), import("@codemirror/search")]);
|
||||
cmModules = { view, state, langJson, commands, search };
|
||||
return cmModules;
|
||||
}
|
||||
|
|
@ -349,10 +343,7 @@ watch(
|
|||
(value) => {
|
||||
if (activeMode.value === "json") {
|
||||
const currentDraft = parseSqlFormatterConfig(jsonDraft.value);
|
||||
if (
|
||||
currentDraft.ok &&
|
||||
serializeSqlFormatterConfig(currentDraft.settings) === serializeSqlFormatterConfig(value)
|
||||
) {
|
||||
if (currentDraft.ok && serializeSqlFormatterConfig(currentDraft.settings) === serializeSqlFormatterConfig(value)) {
|
||||
validateJsonDraft(jsonDraft.value);
|
||||
return;
|
||||
}
|
||||
|
|
@ -403,10 +394,7 @@ onBeforeUnmount(() => {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="importError"
|
||||
class="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive"
|
||||
>
|
||||
<p v-if="importError" class="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{{ importError }}
|
||||
</p>
|
||||
|
||||
|
|
@ -420,10 +408,7 @@ onBeforeUnmount(() => {
|
|||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t("settings.sqlFormatterKeywordCase") }}</Label>
|
||||
<Select
|
||||
:model-value="settings.keywordCase"
|
||||
@update:model-value="(value: any) => onCaseOption('keywordCase', value)"
|
||||
>
|
||||
<Select :model-value="settings.keywordCase" @update:model-value="(value: any) => onCaseOption('keywordCase', value)">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
|
@ -437,10 +422,7 @@ onBeforeUnmount(() => {
|
|||
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t("settings.sqlFormatterFunctionCase") }}</Label>
|
||||
<Select
|
||||
:model-value="settings.functionCase"
|
||||
@update:model-value="(value: any) => onCaseOption('functionCase', value)"
|
||||
>
|
||||
<Select :model-value="settings.functionCase" @update:model-value="(value: any) => onCaseOption('functionCase', value)">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
|
@ -454,10 +436,7 @@ onBeforeUnmount(() => {
|
|||
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t("settings.sqlFormatterDataTypeCase") }}</Label>
|
||||
<Select
|
||||
:model-value="settings.dataTypeCase"
|
||||
@update:model-value="(value: any) => onCaseOption('dataTypeCase', value)"
|
||||
>
|
||||
<Select :model-value="settings.dataTypeCase" @update:model-value="(value: any) => onCaseOption('dataTypeCase', value)">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
|
@ -474,22 +453,10 @@ onBeforeUnmount(() => {
|
|||
<div class="space-y-2">
|
||||
<Label>{{ t("settings.sqlFormatterIndent") }}</Label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="justify-center"
|
||||
:class="!settings.useTabs ? 'border-blue-300 ring-2 ring-blue-300/50' : ''"
|
||||
@click="updateOption('useTabs', false)"
|
||||
>
|
||||
<Button type="button" variant="outline" class="justify-center" :class="!settings.useTabs ? 'border-blue-300 ring-2 ring-blue-300/50' : ''" @click="updateOption('useTabs', false)">
|
||||
{{ t("settings.sqlFormatterIndentSpaces") }}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="justify-center"
|
||||
:class="settings.useTabs ? 'border-blue-300 ring-2 ring-blue-300/50' : ''"
|
||||
@click="updateOption('useTabs', true)"
|
||||
>
|
||||
<Button type="button" variant="outline" class="justify-center" :class="settings.useTabs ? 'border-blue-300 ring-2 ring-blue-300/50' : ''" @click="updateOption('useTabs', true)">
|
||||
{{ t("settings.sqlFormatterIndentTabs") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -557,22 +524,14 @@ onBeforeUnmount(() => {
|
|||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<Label for="sql-formatter-dense-operators">{{ t("settings.sqlFormatterDenseOperators") }}</Label>
|
||||
<Switch
|
||||
id="sql-formatter-dense-operators"
|
||||
:model-value="settings.denseOperators"
|
||||
@update:model-value="(value: boolean) => updateOption('denseOperators', value)"
|
||||
/>
|
||||
<Switch id="sql-formatter-dense-operators" :model-value="settings.denseOperators" @update:model-value="(value: boolean) => updateOption('denseOperators', value)" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<Label for="sql-formatter-newline-before-semicolon">
|
||||
{{ t("settings.sqlFormatterNewlineBeforeSemicolon") }}
|
||||
</Label>
|
||||
<Switch
|
||||
id="sql-formatter-newline-before-semicolon"
|
||||
:model-value="settings.newlineBeforeSemicolon"
|
||||
@update:model-value="(value: boolean) => updateOption('newlineBeforeSemicolon', value)"
|
||||
/>
|
||||
<Switch id="sql-formatter-newline-before-semicolon" :model-value="settings.newlineBeforeSemicolon" @update:model-value="(value: boolean) => updateOption('newlineBeforeSemicolon', value)" />
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
|
@ -592,19 +551,12 @@ onBeforeUnmount(() => {
|
|||
|
||||
<div ref="jsonEditorRef" class="min-h-[260px]" />
|
||||
|
||||
<p
|
||||
v-if="jsonValidationMessage"
|
||||
class="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive"
|
||||
>
|
||||
<p v-if="jsonValidationMessage" class="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{{ jsonValidationMessage }}
|
||||
</p>
|
||||
|
||||
<div class="grid gap-1 rounded-md border border-border/70 bg-muted/20 p-2 sm:grid-cols-2">
|
||||
<div
|
||||
v-for="row in shortcutRows"
|
||||
:key="row.id"
|
||||
class="flex min-w-0 items-center justify-between gap-2 rounded px-1.5 py-1 text-xs"
|
||||
>
|
||||
<div v-for="row in shortcutRows" :key="row.id" class="flex min-w-0 items-center justify-between gap-2 rounded px-1.5 py-1 text-xs">
|
||||
<span class="truncate text-muted-foreground">{{ t(row.labelKey) }}</span>
|
||||
<span class="shrink-0 rounded border bg-background px-1.5 py-0.5 font-mono text-[11px]">
|
||||
{{ row.shortcut }}
|
||||
|
|
|
|||
|
|
@ -449,23 +449,10 @@ function handleImport() {
|
|||
<div class="w-48 shrink-0 flex flex-col gap-2">
|
||||
<div class="text-sm font-medium px-1">{{ t("settings.customThemeMyThemes") }}</div>
|
||||
<div class="flex-1 overflow-y-auto space-y-1 pr-1">
|
||||
<div
|
||||
v-for="theme in localThemes"
|
||||
:key="theme.id"
|
||||
class="group flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer text-sm"
|
||||
:class="activeEditId === theme.id ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
|
||||
@click="activeEditId = theme.id"
|
||||
>
|
||||
<div v-for="theme in localThemes" :key="theme.id" class="group flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer text-sm" :class="activeEditId === theme.id ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'" @click="activeEditId = theme.id">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div v-if="renamingId === theme.id" class="flex items-center gap-1" @click.stop>
|
||||
<Input
|
||||
v-model="renamingName"
|
||||
class="h-6 text-xs px-1 py-0"
|
||||
@keydown.enter="confirmRename"
|
||||
@keydown.esc="cancelRename"
|
||||
@blur="confirmRename"
|
||||
autofocus
|
||||
/>
|
||||
<Input v-model="renamingName" class="h-6 text-xs px-1 py-0" @keydown.enter="confirmRename" @keydown.esc="cancelRename" @blur="confirmRename" autofocus />
|
||||
</div>
|
||||
<div v-else class="truncate">{{ theme.name }}</div>
|
||||
</div>
|
||||
|
|
@ -505,9 +492,7 @@ function handleImport() {
|
|||
{{ token.text }}<sup v-if="token.num" class="text-xl opacity-60">{{ token.num }}</sup>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2 text-lg" :style="{ color: localColors.comment }">
|
||||
<sup class="text-xl">⑥</sup> -- {{ t("settings.customThemePreviewExample") }}
|
||||
</div>
|
||||
<div class="mt-2 text-lg" :style="{ color: localColors.comment }"><sup class="text-xl">⑥</sup> -- {{ t("settings.customThemePreviewExample") }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Preset color schemes -->
|
||||
|
|
@ -532,11 +517,7 @@ function handleImport() {
|
|||
|
||||
<!-- Color configuration list -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div
|
||||
v-for="item in colorItems"
|
||||
:key="item.key"
|
||||
class="relative flex items-center gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<div v-for="item in colorItems" :key="item.key" class="relative flex items-center gap-3 rounded-lg border p-3">
|
||||
<span class="text-xl font-bold w-8 text-center shrink-0">{{ item.num }}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-sm">{{ item.label }}</div>
|
||||
|
|
@ -545,45 +526,20 @@ function handleImport() {
|
|||
<div class="flex items-center gap-2 shrink-0">
|
||||
<!-- Color square + dropdown arrow -->
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-0.5 rounded border p-0.5 hover:bg-muted transition-colors"
|
||||
@click.stop="togglePalette(item.key)"
|
||||
>
|
||||
<button type="button" class="flex items-center gap-0.5 rounded border p-0.5 hover:bg-muted transition-colors" @click.stop="togglePalette(item.key)">
|
||||
<div class="h-6 w-6 rounded-sm" :style="{ backgroundColor: localColors[item.key] }" />
|
||||
<ChevronDown class="h-3 w-3 text-muted-foreground pointer-events-none" />
|
||||
</button>
|
||||
<!-- Palette popup -->
|
||||
<div
|
||||
v-if="expandedPalette === item.key"
|
||||
class="absolute right-0 top-full z-50 mt-1 rounded-lg border bg-popover p-2 shadow-lg"
|
||||
@click.stop
|
||||
>
|
||||
<div v-if="expandedPalette === item.key" class="absolute right-0 top-full z-50 mt-1 rounded-lg border bg-popover p-2 shadow-lg" @click.stop>
|
||||
<div class="space-y-1">
|
||||
<div v-for="(row, rowIndex) in basicColors" :key="rowIndex" class="flex gap-1">
|
||||
<button
|
||||
v-for="color in row"
|
||||
:key="color"
|
||||
type="button"
|
||||
class="h-5 w-5 rounded-sm border border-border/50 hover:scale-110 transition-transform"
|
||||
:style="{ backgroundColor: color }"
|
||||
@click="applyBasicColor(item.key, color)"
|
||||
/>
|
||||
<button v-for="color in row" :key="color" type="button" class="h-5 w-5 rounded-sm border border-border/50 hover:scale-110 transition-transform" :style="{ backgroundColor: color }" @click="applyBasicColor(item.key, color)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 pt-2 border-t flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
:value="localColors[item.key]"
|
||||
@input="handleColorChange(item.key, ($event.target as HTMLInputElement).value)"
|
||||
class="h-6 w-6 cursor-pointer rounded border-0 p-0"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
:value="localColors[item.key]"
|
||||
@input="handleColorChange(item.key, ($event.target as HTMLInputElement).value)"
|
||||
class="w-20 rounded border px-2 py-0.5 text-xs font-mono"
|
||||
/>
|
||||
<input type="color" :value="localColors[item.key]" @input="handleColorChange(item.key, ($event.target as HTMLInputElement).value)" class="h-6 w-6 cursor-pointer rounded border-0 p-0" />
|
||||
<input type="text" :value="localColors[item.key]" @input="handleColorChange(item.key, ($event.target as HTMLInputElement).value)" class="w-20 rounded border px-2 py-0.5 text-xs font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -593,19 +549,10 @@ function handleImport() {
|
|||
</TabsContent>
|
||||
|
||||
<TabsContent value="json" class="space-y-4 flex-1 min-h-0 flex flex-col">
|
||||
<textarea
|
||||
v-model="jsonText"
|
||||
@blur="handleJsonChange"
|
||||
class="flex-1 w-full rounded-lg border bg-black/50 p-4 font-mono text-sm min-h-[360px]"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<textarea v-model="jsonText" @blur="handleJsonChange" class="flex-1 w-full rounded-lg border bg-black/50 p-4 font-mono text-sm min-h-[360px]" spellcheck="false" />
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" size="sm" @click="handleImport">{{
|
||||
t("settings.customThemePasteImport")
|
||||
}}</Button>
|
||||
<Button variant="outline" size="sm" @click="handleExport">{{
|
||||
t("settings.customThemeExportJson")
|
||||
}}</Button>
|
||||
<Button variant="outline" size="sm" @click="handleImport">{{ t("settings.customThemePasteImport") }}</Button>
|
||||
<Button variant="outline" size="sm" @click="handleExport">{{ t("settings.customThemeExportJson") }}</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FolderClosed,
|
||||
FolderOpen,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
} from "@lucide/vue";
|
||||
import { ChevronDown, ChevronRight, FolderClosed, FolderOpen, KeyRound, Loader2, Plus, RefreshCw, Search, Trash2 } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -20,12 +9,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
|
|||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import * as api from "@/lib/api";
|
||||
import type { KvGetResponse, KvKeySummary, KvValue } from "@/lib/api";
|
||||
import {
|
||||
buildEtcdKeyTree,
|
||||
collectEtcdGroupIds,
|
||||
flattenVisibleEtcdKeyTree,
|
||||
type EtcdKeyTreeNode,
|
||||
} from "@/lib/etcdKeyTree";
|
||||
import { buildEtcdKeyTree, collectEtcdGroupIds, flattenVisibleEtcdKeyTree, type EtcdKeyTreeNode } from "@/lib/etcdKeyTree";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
|
||||
const props = defineProps<{ connectionId: string }>();
|
||||
|
|
@ -54,9 +38,7 @@ const pageSize = 200;
|
|||
|
||||
const tree = computed(() => buildEtcdKeyTree(keys.value));
|
||||
const visibleRows = computed(() => flattenVisibleEtcdKeyTree(tree.value, expandedGroupIds.value));
|
||||
const selectedMetadata = computed(
|
||||
() => selectedValue.value?.metadata ?? keys.value.find((key) => key.key === selectedKey.value),
|
||||
);
|
||||
const selectedMetadata = computed(() => selectedValue.value?.metadata ?? keys.value.find((key) => key.key === selectedKey.value));
|
||||
const selectedTextValue = computed(() => {
|
||||
const value = selectedValue.value?.value;
|
||||
if (!value) return "";
|
||||
|
|
@ -84,12 +66,7 @@ async function loadKeys(reset = true) {
|
|||
loadingMore.value = true;
|
||||
}
|
||||
try {
|
||||
const result = await api.etcdListPrefix(
|
||||
props.connectionId,
|
||||
prefix.value.trim(),
|
||||
pageSize,
|
||||
reset ? null : continuation.value,
|
||||
);
|
||||
const result = await api.etcdListPrefix(props.connectionId, prefix.value.trim(), pageSize, reset ? null : continuation.value);
|
||||
const existing = new Set(keys.value.map((key) => key.key));
|
||||
const merged = reset ? result.keys : [...keys.value, ...result.keys.filter((key) => !existing.has(key.key))];
|
||||
keys.value = merged;
|
||||
|
|
@ -204,13 +181,7 @@ defineExpose({ focusSearch });
|
|||
<div class="flex shrink-0 items-center gap-2 border-b px-3 py-2">
|
||||
<div class="relative min-w-0 flex-1">
|
||||
<Search class="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
ref="searchInputRef"
|
||||
v-model="prefix"
|
||||
class="h-8 pl-8"
|
||||
:placeholder="t('etcd.prefixPlaceholder')"
|
||||
@keyup.enter="loadKeys(true)"
|
||||
/>
|
||||
<Input ref="searchInputRef" v-model="prefix" class="h-8 pl-8" :placeholder="t('etcd.prefixPlaceholder')" @keyup.enter="loadKeys(true)" />
|
||||
</div>
|
||||
<Button size="sm" variant="outline" class="h-8 gap-1.5" :disabled="loading" @click="loadKeys(true)">
|
||||
<Loader2 v-if="loading" class="h-3.5 w-3.5 animate-spin" />
|
||||
|
|
@ -229,10 +200,7 @@ defineExpose({ focusSearch });
|
|||
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||
{{ t("etcd.loadingKeys") }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="visibleRows.length === 0"
|
||||
class="flex h-full items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
<div v-else-if="visibleRows.length === 0" class="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{{ t("etcd.empty") }}
|
||||
</div>
|
||||
<div v-else class="h-full overflow-auto py-1 text-sm">
|
||||
|
|
@ -258,13 +226,7 @@ defineExpose({ focusSearch });
|
|||
<span class="truncate">{{ row.node.label }}</span>
|
||||
</button>
|
||||
<div v-if="continuation" class="border-t p-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-8 w-full gap-1.5"
|
||||
:disabled="loadingMore"
|
||||
@click="loadKeys(false)"
|
||||
>
|
||||
<Button size="sm" variant="outline" class="h-8 w-full gap-1.5" :disabled="loadingMore" @click="loadKeys(false)">
|
||||
<Loader2 v-if="loadingMore" class="h-3.5 w-3.5 animate-spin" />
|
||||
{{ t("etcd.loadMore") }}
|
||||
</Button>
|
||||
|
|
@ -305,11 +267,7 @@ defineExpose({ focusSearch });
|
|||
<div v-else-if="selectedValue && !selectedValue.found" class="p-4 text-sm text-muted-foreground">
|
||||
{{ t("etcd.notFound") }}
|
||||
</div>
|
||||
<pre
|
||||
v-else
|
||||
class="dbx-editor-font-family m-0 flex-1 overflow-auto whitespace-pre-wrap break-words p-4 text-sm"
|
||||
>{{ selectedTextValue }}</pre
|
||||
>
|
||||
<pre v-else class="dbx-editor-font-family m-0 flex-1 overflow-auto whitespace-pre-wrap break-words p-4 text-sm">{{ selectedTextValue }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -321,11 +279,7 @@ defineExpose({ focusSearch });
|
|||
</DialogHeader>
|
||||
<div class="grid gap-3 py-2">
|
||||
<Input v-model="editKey" :placeholder="t('etcd.keyPlaceholder')" />
|
||||
<textarea
|
||||
v-model="editValue"
|
||||
class="min-h-52 rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<textarea v-model="editValue" class="min-h-52 rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" spellcheck="false" />
|
||||
<div v-if="editError" class="text-sm text-destructive">{{ editError }}</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
|
|
@ -338,12 +292,6 @@ defineExpose({ focusSearch });
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<DangerConfirmDialog
|
||||
v-model:open="showDeleteConfirm"
|
||||
:title="t('etcd.deleteTitle')"
|
||||
:details="selectedKey || ''"
|
||||
:confirm-label="t('etcd.delete')"
|
||||
@confirm="deleteSelectedKey"
|
||||
/>
|
||||
<DangerConfirmDialog v-model:open="showDeleteConfirm" :title="t('etcd.deleteTitle')" :details="selectedKey || ''" :confirm-label="t('etcd.delete')" @confirm="deleteSelectedKey" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -32,36 +32,21 @@ const rowDiffers = hasActualStats && actualRows !== props.node.rows;
|
|||
<template>
|
||||
<div>
|
||||
<!-- Single line: collapse icon + title + badges all in one row -->
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-1 rounded border bg-background px-2 py-1 text-xs hover:bg-muted/30"
|
||||
:class="{ 'border-green-300 dark:border-green-700': hasActualStats }"
|
||||
@click="toggle"
|
||||
>
|
||||
<div class="flex cursor-pointer items-center gap-1 rounded border bg-background px-2 py-1 text-xs hover:bg-muted/30" :class="{ 'border-green-300 dark:border-green-700': hasActualStats }" @click="toggle">
|
||||
<ChevronRight v-if="node.children.length > 0 && collapsed" class="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<ChevronDown v-else-if="node.children.length > 0" class="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
|
||||
<span class="shrink-0 rounded bg-muted px-1 py-0.5 font-medium">{{ node.nodeType }}</span>
|
||||
<span v-if="node.relation" class="shrink-0 truncate max-w-[120px] text-blue-600 dark:text-blue-400">{{
|
||||
node.relation
|
||||
}}</span>
|
||||
<span v-if="node.relation" class="shrink-0 truncate max-w-[120px] text-blue-600 dark:text-blue-400">{{ node.relation }}</span>
|
||||
<span v-if="node.index" class="shrink-0 text-emerald-600 dark:text-emerald-400">[{{ node.index }}]</span>
|
||||
<span v-if="node.cost" class="shrink-0 tabular-nums text-muted-foreground">c:{{ node.cost }}</span>
|
||||
<span v-if="node.rows" class="shrink-0 tabular-nums text-amber-600 dark:text-amber-400">e:{{ node.rows }}</span>
|
||||
<span
|
||||
v-if="hasActualStats"
|
||||
class="shrink-0 tabular-nums font-semibold"
|
||||
:class="rowDiffers ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'"
|
||||
>a:{{ actualRows
|
||||
}}<span v-if="rowDiffers">({{ Math.round((Number(actualRows) / Number(node.rows)) * 100) }}%)</span></span
|
||||
<span v-if="hasActualStats" class="shrink-0 tabular-nums font-semibold" :class="rowDiffers ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'"
|
||||
>a:{{ actualRows }}<span v-if="rowDiffers">({{ Math.round((Number(actualRows) / Number(node.rows)) * 100) }}%)</span></span
|
||||
>
|
||||
|
||||
<!-- Details collapsed into tooltip on hover -->
|
||||
<span
|
||||
v-if="node.details.length"
|
||||
class="ml-auto shrink-0 overflow-hidden text-ellipsis whitespace-nowrap text-muted-foreground/40"
|
||||
:title="node.details.join('\n')"
|
||||
>{{ node.details.join(" ") }}</span
|
||||
>
|
||||
<span v-if="node.details.length" class="ml-auto shrink-0 overflow-hidden text-ellipsis whitespace-nowrap text-muted-foreground/40" :title="node.details.join('\n')">{{ node.details.join(" ") }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Children (collapsible) -->
|
||||
|
|
|
|||
|
|
@ -47,41 +47,19 @@ const nodeCount = computed(() => (props.plan ? flattenExplainPlanNodes(props.pla
|
|||
<GitBranch class="h-3.5 w-3.5" />
|
||||
{{ t("explain.title") }}
|
||||
</span>
|
||||
<span v-if="plan" class="text-muted-foreground"
|
||||
>{{ plan.databaseType.toUpperCase() }} · {{ t("explain.nodeCount", { count: nodeCount }) }}</span
|
||||
>
|
||||
<span
|
||||
v-if="plan?.databaseType === 'dameng' && isRawString && rawContent.includes('->')"
|
||||
class="ml-1 inline-flex items-center gap-1 rounded bg-green-100 px-1.5 py-0.5 font-semibold text-green-700 dark:bg-green-900/30 dark:text-green-300"
|
||||
style="font-size: 10px"
|
||||
>A-TRACE</span
|
||||
>
|
||||
<span v-if="plan" class="text-muted-foreground">{{ plan.databaseType.toUpperCase() }} · {{ t("explain.nodeCount", { count: nodeCount }) }}</span>
|
||||
<span v-if="plan?.databaseType === 'dameng' && isRawString && rawContent.includes('->')" class="ml-1 inline-flex items-center gap-1 rounded bg-green-100 px-1.5 py-0.5 font-semibold text-green-700 dark:bg-green-900/30 dark:text-green-300" style="font-size: 10px">A-TRACE</span>
|
||||
<span class="flex-1" />
|
||||
<div v-if="plan" class="inline-flex rounded-md border bg-muted/40 p-0.5">
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="activeView === 'tree' ? 'secondary' : 'ghost'"
|
||||
class="h-6 px-2 text-xs gap-1"
|
||||
@click="activeView = 'tree'"
|
||||
>
|
||||
<Button size="sm" :variant="activeView === 'tree' ? 'secondary' : 'ghost'" class="h-6 px-2 text-xs gap-1" @click="activeView = 'tree'">
|
||||
<GitBranch class="h-3.5 w-3.5" />
|
||||
{{ t("explain.tree") }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="activeView === 'summary' ? 'secondary' : 'ghost'"
|
||||
class="h-6 px-2 text-xs gap-1"
|
||||
@click="activeView = 'summary'"
|
||||
>
|
||||
<Button size="sm" :variant="activeView === 'summary' ? 'secondary' : 'ghost'" class="h-6 px-2 text-xs gap-1" @click="activeView = 'summary'">
|
||||
<Table2 class="h-3.5 w-3.5" />
|
||||
{{ t("explain.summary") }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="activeView === 'raw' ? 'secondary' : 'ghost'"
|
||||
class="h-6 px-2 text-xs gap-1"
|
||||
@click="activeView = 'raw'"
|
||||
>
|
||||
<Button size="sm" :variant="activeView === 'raw' ? 'secondary' : 'ghost'" class="h-6 px-2 text-xs gap-1" @click="activeView = 'raw'">
|
||||
<FileText v-if="isRawString" class="h-3.5 w-3.5" />
|
||||
<Braces v-else class="h-3.5 w-3.5" />
|
||||
{{ isRawString ? "TEXT" : "JSON" }}
|
||||
|
|
@ -94,9 +72,7 @@ const nodeCount = computed(() => (props.plan ? flattenExplainPlanNodes(props.pla
|
|||
</div>
|
||||
|
||||
<div v-else-if="error" class="flex-1 min-h-0 flex items-center justify-center">
|
||||
<div
|
||||
class="flex max-w-xl items-start gap-2 rounded border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive"
|
||||
>
|
||||
<div class="flex max-w-xl items-start gap-2 rounded border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
<AlertCircle class="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
|
|
@ -142,11 +118,7 @@ const nodeCount = computed(() => (props.plan ? flattenExplainPlanNodes(props.pla
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<pre
|
||||
v-else
|
||||
class="m-3 overflow-auto whitespace-pre rounded border bg-muted/30 p-3 font-mono text-xs leading-relaxed"
|
||||
>{{ rawContent }}</pre
|
||||
>
|
||||
<pre v-else class="m-3 overflow-auto whitespace-pre rounded border bg-muted/30 p-3 font-mono text-xs leading-relaxed">{{ rawContent }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -65,21 +65,9 @@ const exportCancelled = ref(false);
|
|||
const pendingPrefillTable = ref("");
|
||||
const pendingPrefillTables = ref<string[]>([]);
|
||||
|
||||
const sqlConnections = computed(() =>
|
||||
store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "etcd"].includes(c.db_type)),
|
||||
);
|
||||
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "etcd"].includes(c.db_type)));
|
||||
|
||||
const canExport = computed(
|
||||
() =>
|
||||
connectionId.value &&
|
||||
database.value &&
|
||||
schema.value &&
|
||||
!loadingTables.value &&
|
||||
!tableError.value &&
|
||||
(tables.value.length === 0 || selectedTables.value.length > 0) &&
|
||||
(includeStructure.value || includeData.value || includeObjects.value) &&
|
||||
!isExporting.value,
|
||||
);
|
||||
const canExport = computed(() => connectionId.value && database.value && schema.value && !loadingTables.value && !tableError.value && (tables.value.length === 0 || selectedTables.value.length > 0) && (includeStructure.value || includeData.value || includeObjects.value) && !isExporting.value);
|
||||
|
||||
const selectedTableSet = computed(() => new Set(selectedTables.value));
|
||||
|
||||
|
|
@ -121,12 +109,7 @@ async function loadSchemas(preferredSchema = "") {
|
|||
}
|
||||
|
||||
const schemaList = await api.listSchemas(connectionId.value, database.value);
|
||||
const selected =
|
||||
preferredSchema && schemaList.includes(preferredSchema)
|
||||
? preferredSchema
|
||||
: schemaList.includes("public")
|
||||
? "public"
|
||||
: (schemaList[0] ?? "");
|
||||
const selected = preferredSchema && schemaList.includes(preferredSchema) ? preferredSchema : schemaList.includes("public") ? "public" : (schemaList[0] ?? "");
|
||||
schemas.value = schemaList;
|
||||
schema.value = selected;
|
||||
}
|
||||
|
|
@ -142,12 +125,7 @@ async function loadTables(preferredTable = "", preferredTables: string[] = []) {
|
|||
const names = tableInfos.map((table) => table.name);
|
||||
tables.value = names;
|
||||
const preferredSet = new Set(preferredTables.filter((name) => names.includes(name)));
|
||||
selectedTables.value =
|
||||
preferredSet.size > 0
|
||||
? names.filter((name) => preferredSet.has(name))
|
||||
: preferredTable && names.includes(preferredTable)
|
||||
? [preferredTable]
|
||||
: [...names];
|
||||
selectedTables.value = preferredSet.size > 0 ? names.filter((name) => preferredSet.has(name)) : preferredTable && names.includes(preferredTable) ? [preferredTable] : [...names];
|
||||
} catch (e: any) {
|
||||
tableError.value = e?.message || String(e);
|
||||
} finally {
|
||||
|
|
@ -425,13 +403,7 @@ watch(
|
|||
</Button>
|
||||
</div>
|
||||
<div class="max-h-40 overflow-auto space-y-1 pr-1">
|
||||
<button
|
||||
v-for="table in filteredTables"
|
||||
:key="table"
|
||||
type="button"
|
||||
class="flex w-full min-w-0 items-center gap-2 rounded px-1.5 py-1 text-left text-xs hover:bg-muted"
|
||||
@click="toggleTable(table)"
|
||||
>
|
||||
<button v-for="table in filteredTables" :key="table" type="button" class="flex w-full min-w-0 items-center gap-2 rounded px-1.5 py-1 text-left text-xs hover:bg-muted" @click="toggleTable(table)">
|
||||
<CheckSquare v-if="selectedTableSet.has(table)" class="w-3.5 h-3.5 text-primary shrink-0" />
|
||||
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
|
||||
<span class="truncate">{{ table }}</span>
|
||||
|
|
@ -453,11 +425,7 @@ watch(
|
|||
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
|
||||
{{ t("databaseExport.includeStructure") }}
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 text-xs"
|
||||
:class="includeStructure ? 'cursor-pointer' : 'cursor-not-allowed text-muted-foreground/50'"
|
||||
@click="includeStructure && (dropTableIfExists = !dropTableIfExists)"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-xs" :class="includeStructure ? 'cursor-pointer' : 'cursor-not-allowed text-muted-foreground/50'" @click="includeStructure && (dropTableIfExists = !dropTableIfExists)">
|
||||
<CheckSquare v-if="dropTableIfExists && includeStructure" class="w-3.5 h-3.5 text-primary shrink-0" />
|
||||
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
|
||||
{{ t("databaseExport.dropTableIfExists") }}
|
||||
|
|
@ -489,11 +457,7 @@ watch(
|
|||
</div>
|
||||
|
||||
<div class="w-full bg-muted rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-300"
|
||||
:class="exportError ? 'bg-destructive' : exportCancelled ? 'bg-yellow-500' : 'bg-primary'"
|
||||
:style="{ width: `${progressPercent}%` }"
|
||||
/>
|
||||
<div class="h-full rounded-full transition-all duration-300" :class="exportError ? 'bg-destructive' : exportCancelled ? 'bg-yellow-500' : 'bg-primary'" :style="{ width: `${progressPercent}%` }" />
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -61,12 +61,7 @@ const rowsText = computed(() => {
|
|||
|
||||
<!-- Progress bar -->
|
||||
<div class="w-full bg-muted rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
v-if="status === 'Running' || status === 'Writing'"
|
||||
class="h-full bg-primary rounded-full transition-all duration-300"
|
||||
:class="{ 'animate-pulse': !totalRows }"
|
||||
:style="{ width: totalRows ? `${progressPercent}%` : '50%' }"
|
||||
/>
|
||||
<div v-if="status === 'Running' || status === 'Writing'" class="h-full bg-primary rounded-full transition-all duration-300" :class="{ 'animate-pulse': !totalRows }" :style="{ width: totalRows ? `${progressPercent}%` : '50%' }" />
|
||||
<div v-else-if="status === 'Done'" class="h-full bg-green-500 rounded-full" style="width: 100%" />
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -79,18 +79,9 @@ function toggleShowAll() {
|
|||
<template>
|
||||
<Popover v-if="tasks.length > 0" v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="relative h-8 w-8"
|
||||
:title="t('exportProgress.tooltip')"
|
||||
:class="{ 'bg-accent text-primary': hasActive }"
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="relative h-8 w-8" :title="t('exportProgress.tooltip')" :class="{ 'bg-accent text-primary': hasActive }">
|
||||
<FileDown class="h-4 w-4" />
|
||||
<span
|
||||
v-if="hasActive"
|
||||
class="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium leading-none text-primary-foreground"
|
||||
>
|
||||
<span v-if="hasActive" class="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium leading-none text-primary-foreground">
|
||||
{{ activeCount > 9 ? "9+" : activeCount }}
|
||||
</span>
|
||||
</Button>
|
||||
|
|
@ -102,28 +93,16 @@ function toggleShowAll() {
|
|||
</div>
|
||||
|
||||
<div class="max-h-80 overflow-y-auto">
|
||||
<div
|
||||
v-for="task in visibleTasks"
|
||||
:key="task.exportId"
|
||||
class="flex items-center gap-2 border-b px-3 py-2.5 text-xs last:border-b-0"
|
||||
>
|
||||
<div v-for="task in visibleTasks" :key="task.exportId" class="flex items-center gap-2 border-b px-3 py-2.5 text-xs last:border-b-0">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<component
|
||||
:is="statusIcon(task.status)"
|
||||
:class="[statusColor(task.status), isActive(task.status) ? 'animate-spin' : '']"
|
||||
class="h-3.5 w-3.5 shrink-0"
|
||||
/>
|
||||
<component :is="statusIcon(task.status)" :class="[statusColor(task.status), isActive(task.status) ? 'animate-spin' : '']" class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="truncate font-medium">{{ task.tableName }}.{{ task.format }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div v-if="isActive(task.status)" class="w-full bg-muted rounded-full h-1.5 overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-primary rounded-full transition-all duration-300"
|
||||
:class="{ 'animate-pulse': !task.totalRows }"
|
||||
:style="{ width: task.totalRows ? `${progressPercent(task.totalRows, task.rowsExported)}%` : '50%' }"
|
||||
/>
|
||||
<div class="h-full bg-primary rounded-full transition-all duration-300" :class="{ 'animate-pulse': !task.totalRows }" :style="{ width: task.totalRows ? `${progressPercent(task.totalRows, task.rowsExported)}%` : '50%' }" />
|
||||
</div>
|
||||
<div v-else-if="task.status === 'Done'" class="w-full bg-muted rounded-full h-1.5 overflow-hidden">
|
||||
<div class="h-full bg-green-500 rounded-full" style="width: 100%" />
|
||||
|
|
@ -131,11 +110,7 @@ function toggleShowAll() {
|
|||
|
||||
<div class="flex items-center justify-between text-muted-foreground">
|
||||
<span class="tabular-nums">{{ rowsText(task) }}</span>
|
||||
<span
|
||||
v-if="task.status === 'Error' && task.errorMessage"
|
||||
class="truncate ml-2 text-destructive"
|
||||
:title="task.errorMessage"
|
||||
>
|
||||
<span v-if="task.status === 'Error' && task.errorMessage" class="truncate ml-2 text-destructive" :title="task.errorMessage">
|
||||
{{ task.errorMessage }}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -143,20 +118,10 @@ function toggleShowAll() {
|
|||
|
||||
<!-- Actions: stop/cancel for active, delete for finished -->
|
||||
<div class="flex shrink-0 self-center">
|
||||
<button
|
||||
v-if="isActive(task.status)"
|
||||
class="flex h-6 w-6 items-center justify-center rounded hover:bg-muted"
|
||||
:title="t('exportProgress.cancel')"
|
||||
@click="cancelTask(task.exportId)"
|
||||
>
|
||||
<button v-if="isActive(task.status)" class="flex h-6 w-6 items-center justify-center rounded hover:bg-muted" :title="t('exportProgress.cancel')" @click="cancelTask(task.exportId)">
|
||||
<X class="h-3.5 w-3.5 text-muted-foreground hover:text-destructive" />
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="flex h-6 w-6 items-center justify-center rounded hover:bg-muted"
|
||||
:title="t('exportProgress.delete')"
|
||||
@click="removeTask(task.exportId)"
|
||||
>
|
||||
<button v-else class="flex h-6 w-6 items-center justify-center rounded hover:bg-muted" :title="t('exportProgress.delete')" @click="removeTask(task.exportId)">
|
||||
<X class="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -165,9 +130,7 @@ function toggleShowAll() {
|
|||
|
||||
<div v-if="hasMore" class="border-t bg-muted/30 px-3 py-1.5">
|
||||
<button class="w-full text-center text-xs text-muted-foreground hover:text-foreground" @click="toggleShowAll">
|
||||
{{
|
||||
showAll ? t("exportProgress.showLess") : t("exportProgress.showMore", { count: tasks.length - MAX_VISIBLE })
|
||||
}}
|
||||
{{ showAll ? t("exportProgress.showLess") : t("exportProgress.showMore", { count: tasks.length - MAX_VISIBLE }) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -143,81 +143,37 @@ watch(
|
|||
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="(value) => emit('update:open', value)">
|
||||
<DialogContent
|
||||
:show-close-button="false"
|
||||
class="image-preview-dialog h-[min(86vh,920px)] w-[min(92vw,1280px)] max-w-none gap-0 overflow-hidden rounded-xl border-white/10 bg-[#090b0f] p-0 text-white shadow-2xl"
|
||||
@escape-key-down="close"
|
||||
>
|
||||
<DialogContent :show-close-button="false" class="image-preview-dialog h-[min(86vh,920px)] w-[min(92vw,1280px)] max-w-none gap-0 overflow-hidden rounded-xl border-white/10 bg-[#090b0f] p-0 text-white shadow-2xl" @escape-key-down="close">
|
||||
<div class="flex h-12 shrink-0 items-center gap-3 border-b border-white/10 bg-white/[0.035] px-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<DialogTitle class="truncate text-sm font-semibold text-white">{{ imageTitle }}</DialogTitle>
|
||||
<div class="truncate text-[11px] text-white/45">{{ hostLabel || src }}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 rounded-md border border-white/10 bg-black/20 p-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 text-white/75 hover:bg-white/10 hover:text-white"
|
||||
:title="t('grid.zoomOut')"
|
||||
@click="zoomOut"
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 text-white/75 hover:bg-white/10 hover:text-white" :title="t('grid.zoomOut')" @click="zoomOut">
|
||||
<ZoomOut class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<div class="w-12 text-center text-[11px] tabular-nums text-white/65">{{ zoomLabel }}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 text-white/75 hover:bg-white/10 hover:text-white"
|
||||
:title="t('grid.zoomIn')"
|
||||
@click="zoomIn"
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 text-white/75 hover:bg-white/10 hover:text-white" :title="t('grid.zoomIn')" @click="zoomIn">
|
||||
<ZoomIn class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 text-white/75 hover:bg-white/10 hover:text-white"
|
||||
:title="t('grid.fitImage')"
|
||||
@click="fitImage"
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 text-white/75 hover:bg-white/10 hover:text-white" :title="t('grid.fitImage')" @click="fitImage">
|
||||
<Maximize2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 text-white/75 hover:bg-white/10 hover:text-white"
|
||||
:title="t('grid.openImage')"
|
||||
@click="openExternal"
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 text-white/75 hover:bg-white/10 hover:text-white" :title="t('grid.openImage')" @click="openExternal">
|
||||
<ExternalLink class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 text-white/70 hover:bg-white/10 hover:text-white"
|
||||
:title="t('dangerDialog.cancel')"
|
||||
@click="close"
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8 text-white/70 hover:bg-white/10 hover:text-white" :title="t('dangerDialog.cancel')" @click="close">
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="stageRef"
|
||||
class="image-preview-stage relative min-h-0 flex-1 overflow-hidden"
|
||||
:class="{ 'cursor-grabbing': dragStart, 'cursor-grab': !dragStart && imageLoaded && !imageError }"
|
||||
@wheel="onWheel"
|
||||
>
|
||||
<div ref="stageRef" class="image-preview-stage relative min-h-0 flex-1 overflow-hidden" :class="{ 'cursor-grabbing': dragStart, 'cursor-grab': !dragStart && imageLoaded && !imageError }" @wheel="onWheel">
|
||||
<div v-if="!imageLoaded && !imageError" class="absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
class="h-16 w-16 animate-pulse rounded-full border border-white/10 bg-white/10 shadow-[0_0_80px_rgba(255,255,255,0.12)]"
|
||||
/>
|
||||
<div class="h-16 w-16 animate-pulse rounded-full border border-white/10 bg-white/10 shadow-[0_0_80px_rgba(255,255,255,0.12)]" />
|
||||
</div>
|
||||
<div
|
||||
v-if="imageError"
|
||||
class="absolute inset-0 flex items-center justify-center px-8 text-center text-sm text-white/55"
|
||||
>
|
||||
<div v-if="imageError" class="absolute inset-0 flex items-center justify-center px-8 text-center text-sm text-white/55">
|
||||
{{ t("grid.imageLoadFailed") }}
|
||||
</div>
|
||||
<img
|
||||
|
|
@ -247,10 +203,7 @@ watch(
|
|||
.image-preview-stage {
|
||||
background-color: #07090d;
|
||||
background-image:
|
||||
linear-gradient(45deg, rgba(255, 255, 255, 0.055) 25%, transparent 25%),
|
||||
linear-gradient(-45deg, rgba(255, 255, 255, 0.055) 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, rgba(255, 255, 255, 0.055) 75%),
|
||||
linear-gradient(-45deg, transparent 75%, rgba(255, 255, 255, 0.055) 75%),
|
||||
linear-gradient(45deg, rgba(255, 255, 255, 0.055) 25%, transparent 25%), linear-gradient(-45deg, rgba(255, 255, 255, 0.055) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, rgba(255, 255, 255, 0.055) 75%), linear-gradient(-45deg, transparent 75%, rgba(255, 255, 255, 0.055) 75%),
|
||||
radial-gradient(circle at 50% 30%, rgba(255, 255, 255, 0.08), transparent 42%);
|
||||
background-position:
|
||||
0 0,
|
||||
|
|
|
|||
|
|
@ -436,12 +436,7 @@ onBeforeUnmount(() => cleanupMap());
|
|||
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="(value) => emit('update:open', value)">
|
||||
<DialogContent
|
||||
:show-close-button="false"
|
||||
:style="contentStyle"
|
||||
class="layer-preview-dialog flex w-[96vw] max-w-[1800px] h-[88vh] max-h-[960px] min-w-[640px] min-h-[400px] flex-col gap-0 overflow-hidden rounded-xl border p-0 shadow-2xl"
|
||||
@escape-key-down="close"
|
||||
>
|
||||
<DialogContent :show-close-button="false" :style="contentStyle" class="layer-preview-dialog flex w-[96vw] max-w-[1800px] h-[88vh] max-h-[960px] min-w-[640px] min-h-[400px] flex-col gap-0 overflow-hidden rounded-xl border p-0 shadow-2xl" @escape-key-down="close">
|
||||
<!-- Header -->
|
||||
<div class="flex h-12 shrink-0 items-center gap-2 border-b bg-muted/20 px-3">
|
||||
<div class="flex min-w-0 shrink-0 items-center gap-2">
|
||||
|
|
@ -450,12 +445,7 @@ onBeforeUnmount(() => cleanupMap());
|
|||
</div>
|
||||
|
||||
<!-- Label property selector -->
|
||||
<select
|
||||
v-if="labelProperties.length"
|
||||
v-model="labelProperty"
|
||||
class="h-6 shrink-0 rounded border bg-background px-1.5 text-[11px] outline-none"
|
||||
@change="onLabelPropertyChange"
|
||||
>
|
||||
<select v-if="labelProperties.length" v-model="labelProperty" class="h-6 shrink-0 rounded border bg-background px-1.5 text-[11px] outline-none" @change="onLabelPropertyChange">
|
||||
<option value="">— 标签 —</option>
|
||||
<option v-for="p in labelProperties" :key="p" :value="p">
|
||||
{{ p }}
|
||||
|
|
@ -465,94 +455,43 @@ onBeforeUnmount(() => cleanupMap());
|
|||
<div class="flex flex-1" />
|
||||
|
||||
<!-- Basemap selector -->
|
||||
<select
|
||||
v-model="selectedBasemapId"
|
||||
class="h-6 shrink-0 rounded border bg-background px-1.5 text-[11px] outline-none"
|
||||
>
|
||||
<select v-model="selectedBasemapId" class="h-6 shrink-0 rounded border bg-background px-1.5 text-[11px] outline-none">
|
||||
<option v-for="bm in basemaps" :key="bm.id" :value="bm.id">
|
||||
{{ bm.label }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<!-- Save as image -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 shrink-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
title="导出图片"
|
||||
:disabled="isExporting"
|
||||
@click="saveAsImage"
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground" title="导出图片" :disabled="isExporting" @click="saveAsImage">
|
||||
<Camera v-if="!isExporting" class="h-3.5 w-3.5" />
|
||||
<Loader2 v-else class="h-3.5 w-3.5 animate-spin" />
|
||||
</Button>
|
||||
|
||||
<!-- Maximise -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 shrink-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
:title="isMaximized ? '还原' : '最大化'"
|
||||
@click="toggleMaximize"
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground" :title="isMaximized ? '还原' : '最大化'" @click="toggleMaximize">
|
||||
<Maximize2 v-if="!isMaximized" class="h-3.5 w-3.5" />
|
||||
<Minimize2 v-else class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 shrink-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
:title="t('dangerDialog.cancel')"
|
||||
@click="close"
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground" :title="t('dangerDialog.cancel')" @click="close">
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Map -->
|
||||
<div ref="mapContainer" class="relative w-full flex-1" style="min-height: 200px" data-map-container>
|
||||
<div
|
||||
class="absolute inset-0 z-10 flex items-center justify-center text-xs text-muted-foreground pointer-events-none"
|
||||
data-map-placeholder
|
||||
>
|
||||
Loading map…
|
||||
</div>
|
||||
<div class="absolute inset-0 z-10 flex items-center justify-center text-xs text-muted-foreground pointer-events-none" data-map-placeholder>Loading map…</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="mapError"
|
||||
class="absolute inset-0 flex items-center justify-center bg-background/80 p-4 text-sm text-destructive"
|
||||
>
|
||||
<div v-if="mapError" class="absolute inset-0 flex items-center justify-center bg-background/80 p-4 text-sm text-destructive">
|
||||
{{ mapError }}
|
||||
</div>
|
||||
|
||||
<!-- Resize handles -->
|
||||
<div
|
||||
class="absolute bottom-0 right-0 z-50 h-5 w-5 cursor-se-resize"
|
||||
@pointerdown.prevent="onResizePointerDown($event, 'se')"
|
||||
@pointermove="onResizePointerMove"
|
||||
@pointerup="onResizePointerUp"
|
||||
@pointercancel="onResizePointerUp"
|
||||
>
|
||||
<div
|
||||
class="absolute bottom-0.5 right-0.5 h-2.5 w-2.5"
|
||||
style="border-right: 2px solid; border-bottom: 2px solid; opacity: 0.3"
|
||||
/>
|
||||
<div class="absolute bottom-0 right-0 z-50 h-5 w-5 cursor-se-resize" @pointerdown.prevent="onResizePointerDown($event, 'se')" @pointermove="onResizePointerMove" @pointerup="onResizePointerUp" @pointercancel="onResizePointerUp">
|
||||
<div class="absolute bottom-0.5 right-0.5 h-2.5 w-2.5" style="border-right: 2px solid; border-bottom: 2px solid; opacity: 0.3" />
|
||||
</div>
|
||||
<div
|
||||
class="absolute bottom-0 left-2 right-6 z-50 h-2 cursor-s-resize opacity-0 hover:opacity-25"
|
||||
@pointerdown.prevent="onResizePointerDown($event, 's')"
|
||||
@pointermove="onResizePointerMove"
|
||||
@pointerup="onResizePointerUp"
|
||||
@pointercancel="onResizePointerUp"
|
||||
/>
|
||||
<div
|
||||
class="absolute right-0 top-2 bottom-6 z-50 w-2 cursor-e-resize opacity-0 hover:opacity-25"
|
||||
@pointerdown.prevent="onResizePointerDown($event, 'e')"
|
||||
@pointermove="onResizePointerMove"
|
||||
@pointerup="onResizePointerUp"
|
||||
@pointercancel="onResizePointerUp"
|
||||
/>
|
||||
<div class="absolute bottom-0 left-2 right-6 z-50 h-2 cursor-s-resize opacity-0 hover:opacity-25" @pointerdown.prevent="onResizePointerDown($event, 's')" @pointermove="onResizePointerMove" @pointerup="onResizePointerUp" @pointercancel="onResizePointerUp" />
|
||||
<div class="absolute right-0 top-2 bottom-6 z-50 w-2 cursor-e-resize opacity-0 hover:opacity-25" @pointerdown.prevent="onResizePointerDown($event, 'e')" @pointermove="onResizePointerMove" @pointerup="onResizePointerUp" @pointercancel="onResizePointerUp" />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -37,10 +37,7 @@ const displayValue = computed(() => props.modelValue || "NULL");
|
|||
const triggerClass = computed(() =>
|
||||
props.variant === "inline"
|
||||
? "cell-edit-input flex h-9 w-full items-center gap-2 rounded border bg-background px-2 text-left text-xs outline-none hover:border-primary/60 focus:border-primary"
|
||||
: [
|
||||
"cell-edit-input absolute inset-0 z-10 flex items-center gap-1 border-2 border-primary bg-background py-0 text-left text-xs outline-none",
|
||||
props.cellLayout === "transpose" ? "px-1.5" : "px-2.5",
|
||||
],
|
||||
: ["cell-edit-input absolute inset-0 z-10 flex items-center gap-1 border-2 border-primary bg-background py-0 text-left text-xs outline-none", props.cellLayout === "transpose" ? "px-1.5" : "px-2.5"],
|
||||
);
|
||||
const dateParts = computed(() => {
|
||||
const text = formatTemporalInputValue(props.modelValue, "date");
|
||||
|
|
@ -131,16 +128,8 @@ function setNull() {
|
|||
|
||||
function setNow() {
|
||||
const now = new Date();
|
||||
const dateText = [
|
||||
String(now.getFullYear()).padStart(4, "0"),
|
||||
String(now.getMonth() + 1).padStart(2, "0"),
|
||||
String(now.getDate()).padStart(2, "0"),
|
||||
].join("-");
|
||||
const nextTime = [
|
||||
String(now.getHours()).padStart(2, "0"),
|
||||
String(now.getMinutes()).padStart(2, "0"),
|
||||
String(now.getSeconds()).padStart(2, "0"),
|
||||
].join(":");
|
||||
const dateText = [String(now.getFullYear()).padStart(4, "0"), String(now.getMonth() + 1).padStart(2, "0"), String(now.getDate()).padStart(2, "0")].join("-");
|
||||
const nextTime = [String(now.getHours()).padStart(2, "0"), String(now.getMinutes()).padStart(2, "0"), String(now.getSeconds()).padStart(2, "0")].join(":");
|
||||
if (props.kind === "date") setModelValue(dateText);
|
||||
else if (props.kind === "time") setModelValue(nextTime);
|
||||
else setModelValue(`${dateText} ${nextTime}`);
|
||||
|
|
@ -186,9 +175,7 @@ function normalizeTimePart(value: string | number, max: number): string {
|
|||
}
|
||||
|
||||
function setDateTimeValue(year: number, month: number, day: number, time: string) {
|
||||
const dateText = [String(year).padStart(4, "0"), String(month).padStart(2, "0"), String(day).padStart(2, "0")].join(
|
||||
"-",
|
||||
);
|
||||
const dateText = [String(year).padStart(4, "0"), String(month).padStart(2, "0"), String(day).padStart(2, "0")].join("-");
|
||||
if (props.kind === "date") setModelValue(dateText);
|
||||
else setModelValue(`${dateText} ${time}`);
|
||||
}
|
||||
|
|
@ -210,82 +197,39 @@ function twoDigit(value: string | number): string {
|
|||
<span class="min-w-0 flex-1 truncate">{{ displayValue }}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="bottom"
|
||||
class="w-auto gap-1.5 rounded-md p-1.5"
|
||||
@click.stop
|
||||
@keydown.stop="onKeydown"
|
||||
@interact-outside="onPopoverInteractOutside"
|
||||
>
|
||||
<PopoverContent align="start" side="bottom" class="w-auto gap-1.5 rounded-md p-1.5" @click.stop @keydown.stop="onKeydown" @interact-outside="onPopoverInteractOutside">
|
||||
<div v-if="hasDate" class="grid grid-cols-[4.5rem_4.5rem_4.5rem] gap-1.5">
|
||||
<div
|
||||
class="grid h-7 min-w-0 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background"
|
||||
>
|
||||
<input
|
||||
:value="dateParts.year"
|
||||
data-temporal-part="year"
|
||||
inputmode="numeric"
|
||||
class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none"
|
||||
@change="updateDateFromInput('year', $event)"
|
||||
/>
|
||||
<div class="grid h-7 min-w-0 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background">
|
||||
<input :value="dateParts.year" data-temporal-part="year" inputmode="numeric" class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none" @change="updateDateFromInput('year', $event)" />
|
||||
<div class="grid border-l">
|
||||
<button type="button" class="flex items-center justify-center hover:bg-muted" @click="stepDate('year', 1)">
|
||||
<ChevronUp class="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center border-t hover:bg-muted"
|
||||
@click="stepDate('year', -1)"
|
||||
>
|
||||
<button type="button" class="flex items-center justify-center border-t hover:bg-muted" @click="stepDate('year', -1)">
|
||||
<ChevronDown class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid h-7 min-w-0 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background"
|
||||
>
|
||||
<input
|
||||
:value="twoDigit(dateParts.month)"
|
||||
data-temporal-part="month"
|
||||
inputmode="numeric"
|
||||
class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none"
|
||||
@change="updateDateFromInput('month', $event)"
|
||||
/>
|
||||
<div class="grid h-7 min-w-0 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background">
|
||||
<input :value="twoDigit(dateParts.month)" data-temporal-part="month" inputmode="numeric" class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none" @change="updateDateFromInput('month', $event)" />
|
||||
<div class="grid border-l">
|
||||
<button type="button" class="flex items-center justify-center hover:bg-muted" @click="stepDate('month', 1)">
|
||||
<ChevronUp class="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center border-t hover:bg-muted"
|
||||
@click="stepDate('month', -1)"
|
||||
>
|
||||
<button type="button" class="flex items-center justify-center border-t hover:bg-muted" @click="stepDate('month', -1)">
|
||||
<ChevronDown class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid h-7 min-w-0 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background"
|
||||
>
|
||||
<input
|
||||
:value="twoDigit(dateParts.day)"
|
||||
data-temporal-part="day"
|
||||
inputmode="numeric"
|
||||
class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none"
|
||||
@change="updateDateFromInput('day', $event)"
|
||||
/>
|
||||
<div class="grid h-7 min-w-0 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background">
|
||||
<input :value="twoDigit(dateParts.day)" data-temporal-part="day" inputmode="numeric" class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none" @change="updateDateFromInput('day', $event)" />
|
||||
<div class="grid border-l">
|
||||
<button type="button" class="flex items-center justify-center hover:bg-muted" @click="stepDate('day', 1)">
|
||||
<ChevronUp class="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center border-t hover:bg-muted"
|
||||
@click="stepDate('day', -1)"
|
||||
>
|
||||
<button type="button" class="flex items-center justify-center border-t hover:bg-muted" @click="stepDate('day', -1)">
|
||||
<ChevronDown class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -293,81 +237,37 @@ function twoDigit(value: string | number): string {
|
|||
</div>
|
||||
|
||||
<div v-if="hasTime" class="grid grid-cols-[3.5rem_0.5rem_3.5rem_0.5rem_3.5rem] items-center gap-1.5">
|
||||
<div
|
||||
class="grid h-7 min-w-0 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background"
|
||||
>
|
||||
<input
|
||||
:value="twoDigit(timeParts.hour)"
|
||||
data-temporal-part="hour"
|
||||
inputmode="numeric"
|
||||
class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none"
|
||||
@change="updateTimeFromInput('hour', $event)"
|
||||
/>
|
||||
<div class="grid h-7 min-w-0 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background">
|
||||
<input :value="twoDigit(timeParts.hour)" data-temporal-part="hour" inputmode="numeric" class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none" @change="updateTimeFromInput('hour', $event)" />
|
||||
<div class="grid border-l">
|
||||
<button type="button" class="flex items-center justify-center hover:bg-muted" @click="stepTime('hour', 1)">
|
||||
<ChevronUp class="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center border-t hover:bg-muted"
|
||||
@click="stepTime('hour', -1)"
|
||||
>
|
||||
<button type="button" class="flex items-center justify-center border-t hover:bg-muted" @click="stepTime('hour', -1)">
|
||||
<ChevronDown class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-center text-xs text-muted-foreground">:</span>
|
||||
<div
|
||||
class="grid h-7 min-w-0 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background"
|
||||
>
|
||||
<input
|
||||
:value="twoDigit(timeParts.minute)"
|
||||
data-temporal-part="minute"
|
||||
inputmode="numeric"
|
||||
class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none"
|
||||
@change="updateTimeFromInput('minute', $event)"
|
||||
/>
|
||||
<div class="grid h-7 min-w-0 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background">
|
||||
<input :value="twoDigit(timeParts.minute)" data-temporal-part="minute" inputmode="numeric" class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none" @change="updateTimeFromInput('minute', $event)" />
|
||||
<div class="grid border-l">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center hover:bg-muted"
|
||||
@click="stepTime('minute', 1)"
|
||||
>
|
||||
<button type="button" class="flex items-center justify-center hover:bg-muted" @click="stepTime('minute', 1)">
|
||||
<ChevronUp class="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center border-t hover:bg-muted"
|
||||
@click="stepTime('minute', -1)"
|
||||
>
|
||||
<button type="button" class="flex items-center justify-center border-t hover:bg-muted" @click="stepTime('minute', -1)">
|
||||
<ChevronDown class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-center text-xs text-muted-foreground">:</span>
|
||||
<div
|
||||
class="grid h-7 min-w-0 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background"
|
||||
>
|
||||
<input
|
||||
:value="twoDigit(timeParts.second)"
|
||||
data-temporal-part="second"
|
||||
inputmode="numeric"
|
||||
class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none"
|
||||
@change="updateTimeFromInput('second', $event)"
|
||||
/>
|
||||
<div class="grid h-7 min-w-0 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background">
|
||||
<input :value="twoDigit(timeParts.second)" data-temporal-part="second" inputmode="numeric" class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none" @change="updateTimeFromInput('second', $event)" />
|
||||
<div class="grid border-l">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center hover:bg-muted"
|
||||
@click="stepTime('second', 1)"
|
||||
>
|
||||
<button type="button" class="flex items-center justify-center hover:bg-muted" @click="stepTime('second', 1)">
|
||||
<ChevronUp class="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center border-t hover:bg-muted"
|
||||
@click="stepTime('second', -1)"
|
||||
>
|
||||
<button type="button" class="flex items-center justify-center border-t hover:bg-muted" @click="stepTime('second', -1)">
|
||||
<ChevronDown class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -20,13 +20,7 @@ watch(
|
|||
},
|
||||
);
|
||||
|
||||
const usesWhiteDarkIcon = computed(
|
||||
() =>
|
||||
props.provider === "claude" ||
|
||||
props.provider === "ollama" ||
|
||||
props.provider === "openai" ||
|
||||
props.provider === "openai-compatible",
|
||||
);
|
||||
const usesWhiteDarkIcon = computed(() => props.provider === "claude" || props.provider === "ollama" || props.provider === "openai" || props.provider === "openai-compatible");
|
||||
const localIconUrl = computed(() => {
|
||||
if (props.provider === "openai-compatible") return "/icons/ai/openai.svg";
|
||||
return props.iconSlug ? `/icons/ai/${props.iconSlug}.svg` : "";
|
||||
|
|
@ -40,14 +34,7 @@ const fallbackText = computed(() => {
|
|||
<template>
|
||||
<span class="flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-sm">
|
||||
<Settings2 v-if="provider === 'custom'" class="h-4 w-4 text-muted-foreground" />
|
||||
<img
|
||||
v-else-if="localIconUrl && !failed"
|
||||
:src="localIconUrl"
|
||||
:alt="label"
|
||||
class="h-4 w-4 object-contain"
|
||||
:class="{ 'dark:invert': isDark && usesWhiteDarkIcon }"
|
||||
@error="failed = true"
|
||||
/>
|
||||
<img v-else-if="localIconUrl && !failed" :src="localIconUrl" :alt="label" class="h-4 w-4 object-contain" :class="{ 'dark:invert': isDark && usesWhiteDarkIcon }" @error="failed = true" />
|
||||
<span v-else class="flex h-4 w-4 items-center justify-center rounded-sm bg-muted text-[8px] font-semibold">
|
||||
{{ fallbackText }}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -81,9 +81,7 @@ const normalizedType = computed(() => props.dbType.toLowerCase().replace(/[\s-]+
|
|||
const assetName = computed(() => assetIcons[normalizedType.value]);
|
||||
const assetSrc = computed(() => {
|
||||
if (!assetName.value) return "";
|
||||
return assetName.value.includes(".")
|
||||
? `/icons/database/${assetName.value}`
|
||||
: `/icons/database/${assetName.value}.svg`;
|
||||
return assetName.value.includes(".") ? `/icons/database/${assetName.value}` : `/icons/database/${assetName.value}.svg`;
|
||||
});
|
||||
const letter = computed(() => letterIcons[normalizedType.value]);
|
||||
</script>
|
||||
|
|
@ -92,15 +90,7 @@ const letter = computed(() => letterIcons[normalizedType.value]);
|
|||
<img v-if="assetName" :src="assetSrc" alt="" class="database-logo object-contain" aria-hidden="true" />
|
||||
<svg v-else-if="letter" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="12" :fill="letter.color" />
|
||||
<text
|
||||
x="12"
|
||||
y="16.5"
|
||||
text-anchor="middle"
|
||||
fill="white"
|
||||
font-size="14"
|
||||
font-weight="bold"
|
||||
font-family="system-ui, sans-serif"
|
||||
>
|
||||
<text x="12" y="16.5" text-anchor="middle" fill="white" font-size="14" font-weight="bold" font-family="system-ui, sans-serif">
|
||||
{{ letter.letter }}
|
||||
</text>
|
||||
</svg>
|
||||
|
|
|
|||
|
|
@ -42,9 +42,7 @@ const progress = ref<api.TableImportProgress | null>(null);
|
|||
const errorMessage = ref("");
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const selectedConnection = computed(() =>
|
||||
props.prefillConnectionId ? store.getConfig(props.prefillConnectionId) : undefined,
|
||||
);
|
||||
const selectedConnection = computed(() => (props.prefillConnectionId ? store.getConfig(props.prefillConnectionId) : undefined));
|
||||
const targetColumnNames = computed(() => targetColumns.value.map((column) => column.name));
|
||||
const mappedColumns = computed<api.TableImportColumnMapping[]>(() => {
|
||||
const currentPreview = preview.value;
|
||||
|
|
@ -57,26 +55,14 @@ const mappedColumns = computed<api.TableImportColumnMapping[]>(() => {
|
|||
.filter((mapping) => mapping.targetColumn);
|
||||
});
|
||||
const mappedCount = computed(() => mappedColumns.value.length);
|
||||
const canImport = computed(
|
||||
() =>
|
||||
!!preview.value &&
|
||||
!!props.prefillConnectionId &&
|
||||
!!props.prefillTable &&
|
||||
mappedColumns.value.length > 0 &&
|
||||
!running.value,
|
||||
);
|
||||
const canImport = computed(() => !!preview.value && !!props.prefillConnectionId && !!props.prefillTable && mappedColumns.value.length > 0 && !running.value);
|
||||
const progressPercent = computed(() => {
|
||||
const p = progress.value;
|
||||
if (!p || p.totalRows <= 0) return 0;
|
||||
return Math.min(100, Math.round((p.rowsImported / p.totalRows) * 100));
|
||||
});
|
||||
const targetLabel = computed(() => {
|
||||
const pieces = [
|
||||
selectedConnection.value?.name,
|
||||
props.prefillDatabase,
|
||||
props.prefillSchema,
|
||||
props.prefillTable,
|
||||
].filter(Boolean);
|
||||
const pieces = [selectedConnection.value?.name, props.prefillDatabase, props.prefillSchema, props.prefillTable].filter(Boolean);
|
||||
return pieces.join(" / ");
|
||||
});
|
||||
|
||||
|
|
@ -105,12 +91,7 @@ async function loadTargetColumns() {
|
|||
errorMessage.value = "";
|
||||
try {
|
||||
await store.ensureConnected(props.prefillConnectionId);
|
||||
targetColumns.value = await api.getColumns(
|
||||
props.prefillConnectionId,
|
||||
props.prefillDatabase,
|
||||
props.prefillSchema || props.prefillDatabase,
|
||||
props.prefillTable,
|
||||
);
|
||||
targetColumns.value = await api.getColumns(props.prefillConnectionId, props.prefillDatabase, props.prefillSchema || props.prefillDatabase, props.prefillTable);
|
||||
applyAutoMapping();
|
||||
} catch (e: any) {
|
||||
errorMessage.value = String(e?.message || e);
|
||||
|
|
@ -255,13 +236,7 @@ watch(
|
|||
|
||||
<div class="space-y-4 py-2">
|
||||
<div class="grid grid-cols-[1fr_auto] gap-2">
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".csv,.tsv,.json,.xlsx,.xlsm,.xls"
|
||||
class="hidden"
|
||||
@change="handleFileInputChange"
|
||||
/>
|
||||
<input ref="fileInput" type="file" accept=".csv,.tsv,.json,.xlsx,.xlsm,.xls" class="hidden" @change="handleFileInputChange" />
|
||||
<div class="min-w-0 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div class="truncate text-xs text-muted-foreground">{{ t("tableImport.target") }}</div>
|
||||
<div class="truncate text-sm font-medium">
|
||||
|
|
@ -294,18 +269,11 @@ watch(
|
|||
<div class="rounded-md border">
|
||||
<div class="border-b px-3 py-2 text-xs font-medium">{{ t("tableImport.mapping") }}</div>
|
||||
<div class="max-h-[280px] overflow-auto p-2">
|
||||
<div
|
||||
v-for="sourceColumn in preview.columns"
|
||||
:key="sourceColumn"
|
||||
class="grid grid-cols-[1fr_1fr] items-center gap-2 py-1"
|
||||
>
|
||||
<div v-for="sourceColumn in preview.columns" :key="sourceColumn" class="grid grid-cols-[1fr_1fr] items-center gap-2 py-1">
|
||||
<div class="truncate font-mono text-xs" :title="sourceColumn">
|
||||
{{ sourceColumn }}
|
||||
</div>
|
||||
<Select
|
||||
:model-value="columnMapping[sourceColumn] || SKIP_VALUE"
|
||||
@update:model-value="(value: any) => updateMapping(sourceColumn, value)"
|
||||
>
|
||||
<Select :model-value="columnMapping[sourceColumn] || SKIP_VALUE" @update:model-value="(value: any) => updateMapping(sourceColumn, value)">
|
||||
<SelectTrigger class="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
|
@ -326,23 +294,14 @@ watch(
|
|||
<table class="min-w-full border-separate border-spacing-0 text-xs">
|
||||
<thead class="sticky top-0 bg-background">
|
||||
<tr>
|
||||
<th
|
||||
v-for="column in preview.columns"
|
||||
:key="column"
|
||||
class="border-b border-r px-2 py-1.5 text-left font-medium"
|
||||
>
|
||||
<th v-for="column in preview.columns" :key="column" class="border-b border-r px-2 py-1.5 text-left font-medium">
|
||||
<span class="block max-w-[140px] truncate">{{ column }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, rowIndex) in preview.rows" :key="rowIndex">
|
||||
<td
|
||||
v-for="(cell, colIndex) in row"
|
||||
:key="colIndex"
|
||||
class="max-w-[180px] border-b border-r px-2 py-1.5 font-mono"
|
||||
:class="{ 'text-muted-foreground': cell === null }"
|
||||
>
|
||||
<td v-for="(cell, colIndex) in row" :key="colIndex" class="max-w-[180px] border-b border-r px-2 py-1.5 font-mono" :class="{ 'text-muted-foreground': cell === null }">
|
||||
<span class="block truncate">{{ formatCell(cell) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -375,10 +334,7 @@ watch(
|
|||
<Loader2 v-if="running && !cancelling" class="h-3.5 w-3.5 animate-spin text-primary" />
|
||||
<Square v-else-if="cancelling" class="h-3.5 w-3.5 fill-current text-destructive" />
|
||||
<Check v-else class="h-3.5 w-3.5 text-emerald-600" />
|
||||
<span class="truncate">
|
||||
{{ progress?.rowsImported ?? 0 }} / {{ progress?.totalRows ?? preview.totalRows }} ·
|
||||
{{ progressPercent }}%
|
||||
</span>
|
||||
<span class="truncate"> {{ progress?.rowsImported ?? 0 }} / {{ progress?.totalRows ?? preview.totalRows }} · {{ progressPercent }}% </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -387,10 +343,7 @@ watch(
|
|||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
{{ t("common.loading") }}
|
||||
</div>
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive"
|
||||
>
|
||||
<div v-if="errorMessage" class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -106,13 +106,7 @@ watch(
|
|||
@connect-failed="emit('connectFailed', $event)"
|
||||
@open-driver-store="emit('openDriverStore')"
|
||||
/>
|
||||
<EditorSettingsDialog
|
||||
v-if="showSettingsDialog"
|
||||
:open="showSettingsDialog"
|
||||
:initial-tab="settingsInitialTab || 'editor'"
|
||||
:app-version="appVersion"
|
||||
@update:open="emit('update:showSettingsDialog', $event)"
|
||||
/>
|
||||
<EditorSettingsDialog v-if="showSettingsDialog" :open="showSettingsDialog" :initial-tab="settingsInitialTab || 'editor'" :app-version="appVersion" @update:open="emit('update:showSettingsDialog', $event)" />
|
||||
<DangerConfirmDialog
|
||||
v-if="showDangerDialog"
|
||||
:open="showDangerDialog"
|
||||
|
|
@ -123,19 +117,8 @@ watch(
|
|||
@update:suppress-future-prompts="emit('update:suppressDangerConfirm', $event)"
|
||||
@confirm="emit('dangerConfirm')"
|
||||
/>
|
||||
<DataTransferDialog
|
||||
v-if="dialogs.showTransferDialog.value"
|
||||
v-model:open="dialogs.showTransferDialog.value"
|
||||
:prefill-connection-id="dialogs.transferPrefillConnectionId.value"
|
||||
:prefill-database="dialogs.transferPrefillDatabase.value"
|
||||
/>
|
||||
<SchemaDiffDialog
|
||||
v-if="dialogs.showSchemaDiffDialog.value"
|
||||
v-model:open="dialogs.showSchemaDiffDialog.value"
|
||||
:prefill-connection-id="dialogs.schemaDiffPrefillConnectionId.value"
|
||||
:prefill-database="dialogs.schemaDiffPrefillDatabase.value"
|
||||
:prefill-schema="dialogs.schemaDiffPrefillSchema.value"
|
||||
/>
|
||||
<DataTransferDialog v-if="dialogs.showTransferDialog.value" v-model:open="dialogs.showTransferDialog.value" :prefill-connection-id="dialogs.transferPrefillConnectionId.value" :prefill-database="dialogs.transferPrefillDatabase.value" />
|
||||
<SchemaDiffDialog v-if="dialogs.showSchemaDiffDialog.value" v-model:open="dialogs.showSchemaDiffDialog.value" :prefill-connection-id="dialogs.schemaDiffPrefillConnectionId.value" :prefill-database="dialogs.schemaDiffPrefillDatabase.value" :prefill-schema="dialogs.schemaDiffPrefillSchema.value" />
|
||||
<DataCompareDialog
|
||||
v-if="dialogs.showDataCompareDialog.value"
|
||||
v-model:open="dialogs.showDataCompareDialog.value"
|
||||
|
|
@ -144,12 +127,7 @@ watch(
|
|||
:prefill-schema="dialogs.dataComparePrefillSchema.value"
|
||||
:prefill-table="dialogs.dataComparePrefillTable.value"
|
||||
/>
|
||||
<SqlFileExecutionDialog
|
||||
v-if="dialogs.showSqlFileDialog.value"
|
||||
v-model:open="dialogs.showSqlFileDialog.value"
|
||||
:prefill-connection-id="dialogs.sqlFilePrefillConnectionId.value"
|
||||
:prefill-database="dialogs.sqlFilePrefillDatabase.value"
|
||||
/>
|
||||
<SqlFileExecutionDialog v-if="dialogs.showSqlFileDialog.value" v-model:open="dialogs.showSqlFileDialog.value" :prefill-connection-id="dialogs.sqlFilePrefillConnectionId.value" :prefill-database="dialogs.sqlFilePrefillDatabase.value" />
|
||||
<SchemaDiagramDialog
|
||||
v-if="dialogs.showDiagramDialog.value"
|
||||
v-model:open="dialogs.showDiagramDialog.value"
|
||||
|
|
@ -198,11 +176,7 @@ watch(
|
|||
v-model:open="dialogs.showConfigPassphraseDialog.value"
|
||||
:mode="dialogs.configPassphraseMode.value"
|
||||
:external-error="dialogs.configPassphraseError.value"
|
||||
@confirm="
|
||||
dialogs.configPassphraseMode.value === 'export'
|
||||
? dialogs.onExportConfirm($event)
|
||||
: dialogs.onImportConfirm($event)
|
||||
"
|
||||
@confirm="dialogs.configPassphraseMode.value === 'export' ? dialogs.onExportConfirm($event) : dialogs.onImportConfirm($event)"
|
||||
/>
|
||||
<Dialog v-model:open="dialogs.showImportLayoutConfirm.value">
|
||||
<DialogContent class="sm:max-w-[400px]">
|
||||
|
|
@ -211,9 +185,7 @@ watch(
|
|||
</DialogHeader>
|
||||
<p class="text-sm text-muted-foreground">{{ t("configExport.importLayoutConfirm") }}</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="dialogs.showImportLayoutConfirm.value = false">{{
|
||||
t("dangerDialog.cancel")
|
||||
}}</Button>
|
||||
<Button variant="outline" @click="dialogs.showImportLayoutConfirm.value = false">{{ t("dangerDialog.cancel") }}</Button>
|
||||
<Button
|
||||
@click="
|
||||
dialogs.showImportLayoutConfirm.value = false;
|
||||
|
|
|
|||
|
|
@ -53,19 +53,10 @@ defineExpose({ focusSearch });
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="h-full shrink-0 relative select-none"
|
||||
:class="classicLayout ? '' : 'rounded-md border border-border/80 bg-background'"
|
||||
:style="{ width: sidebarWidth + 'px' }"
|
||||
>
|
||||
<div class="h-full shrink-0 relative select-none" :class="classicLayout ? '' : 'rounded-md border border-border/80 bg-background'" :style="{ width: sidebarWidth + 'px' }">
|
||||
<div class="h-full flex flex-col overflow-hidden">
|
||||
<div
|
||||
class="flex items-center gap-px px-3 text-xs font-medium text-muted-foreground border-b bg-muted/20"
|
||||
:class="classicLayout ? 'h-9' : 'h-10'"
|
||||
>
|
||||
<span class="flex self-stretch items-center truncate" data-tauri-drag-region>{{
|
||||
t("sidebar.connections")
|
||||
}}</span>
|
||||
<div class="flex items-center gap-px px-3 text-xs font-medium text-muted-foreground border-b bg-muted/20" :class="classicLayout ? 'h-9' : 'h-10'">
|
||||
<span class="flex self-stretch items-center truncate" data-tauri-drag-region>{{ t("sidebar.connections") }}</span>
|
||||
<span class="flex-1 self-stretch" data-tauri-drag-region />
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
|
|
|
|||
|
|
@ -2,19 +2,7 @@
|
|||
import { computed, ref, watch, nextTick } from "vue";
|
||||
import type { CSSProperties } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
X,
|
||||
Pin,
|
||||
ChevronDown,
|
||||
Table2,
|
||||
Code2,
|
||||
TableProperties,
|
||||
PencilRuler,
|
||||
KeyRound,
|
||||
Pencil,
|
||||
Package,
|
||||
Check,
|
||||
} from "@lucide/vue";
|
||||
import { X, Pin, ChevronDown, Table2, Code2, TableProperties, PencilRuler, KeyRound, Pencil, Package, Check } from "@lucide/vue";
|
||||
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||||
import LightDropdown from "@/components/ui/LightDropdown.vue";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
|
|
@ -120,15 +108,7 @@ function getTabMenuItems(tab: QueryTab): ContextMenuItem[] {
|
|||
}
|
||||
|
||||
const tabsContainerRef = ref<HTMLElement | null>(null);
|
||||
const {
|
||||
hasTabOverflow,
|
||||
scrollThumbLeftPercent,
|
||||
scrollThumbWidthPercent,
|
||||
isScrollbarDragging,
|
||||
updateScrollButtons,
|
||||
onTabsWheel,
|
||||
startScrollbarDrag,
|
||||
} = useTabScroll(tabsContainerRef);
|
||||
const { hasTabOverflow, scrollThumbLeftPercent, scrollThumbWidthPercent, isScrollbarDragging, updateScrollButtons, onTabsWheel, startScrollbarDrag } = useTabScroll(tabsContainerRef);
|
||||
const tabScrollBehavior = ref<ScrollBehavior>("smooth");
|
||||
|
||||
watch(
|
||||
|
|
@ -199,8 +179,7 @@ function tabColorStyle(tab: QueryTab) {
|
|||
}
|
||||
|
||||
function tabIconClass(tab: QueryTab) {
|
||||
if (tab.mode === "data" || tab.mode === "objects" || tab.mode === "structure")
|
||||
return "text-emerald-600 dark:text-emerald-400";
|
||||
if (tab.mode === "data" || tab.mode === "objects" || tab.mode === "structure") return "text-emerald-600 dark:text-emerald-400";
|
||||
return "text-blue-600 dark:text-blue-400";
|
||||
}
|
||||
|
||||
|
|
@ -259,9 +238,7 @@ const tabScrollbarThumbStyle = computed<CSSProperties>(() => ({
|
|||
width: `${scrollThumbWidthPercent.value}%`,
|
||||
}));
|
||||
|
||||
const tabTailDragRegionClass = computed(() =>
|
||||
showTabOverflowControls.value ? "w-0 flex-none self-stretch" : "min-w-8 flex-1 self-stretch",
|
||||
);
|
||||
const tabTailDragRegionClass = computed(() => (showTabOverflowControls.value ? "w-0 flex-none self-stretch" : "min-w-8 flex-1 self-stretch"));
|
||||
|
||||
const tabOverflowControlClass = computed(() =>
|
||||
settingsStore.editorSettings.appLayout === "classic"
|
||||
|
|
@ -271,9 +248,7 @@ const tabOverflowControlClass = computed(() =>
|
|||
|
||||
function dispatchBeforeTabSwitch(tabId: string) {
|
||||
if (tabId === queryStore.activeTabId) return;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("dbx:before-tab-switch", { detail: { tabId, fromTabId: queryStore.activeTabId } }),
|
||||
);
|
||||
window.dispatchEvent(new CustomEvent("dbx:before-tab-switch", { detail: { tabId, fromTabId: queryStore.activeTabId } }));
|
||||
}
|
||||
|
||||
function activateTab(tabId: string) {
|
||||
|
|
@ -285,62 +260,22 @@ function activateTab(tabId: string) {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="queryStore.tabs.length > 0 || showDriverStore"
|
||||
class="relative flex border-b shrink-0"
|
||||
:class="
|
||||
settingsStore.editorSettings.appLayout === 'classic'
|
||||
? 'h-9 items-stretch bg-muted'
|
||||
: 'h-10 items-center bg-background px-2'
|
||||
"
|
||||
>
|
||||
<div v-if="queryStore.tabs.length > 0 || showDriverStore" class="relative flex border-b shrink-0" :class="settingsStore.editorSettings.appLayout === 'classic' ? 'h-9 items-stretch bg-muted' : 'h-10 items-center bg-background px-2'">
|
||||
<div class="relative h-full min-w-0 flex-1">
|
||||
<div
|
||||
v-if="showTabOverflowControls"
|
||||
class="app-tab-scrollbar"
|
||||
:class="{ 'app-tab-scrollbar--dragging': isScrollbarDragging }"
|
||||
@pointerdown="startScrollbarDrag"
|
||||
>
|
||||
<div v-if="showTabOverflowControls" class="app-tab-scrollbar" :class="{ 'app-tab-scrollbar--dragging': isScrollbarDragging }" @pointerdown="startScrollbarDrag">
|
||||
<div class="app-tab-scrollbar__thumb" :style="tabScrollbarThumbStyle" />
|
||||
</div>
|
||||
<div
|
||||
ref="tabsContainerRef"
|
||||
class="app-tab-scroll flex min-w-0 flex-1 items-center overflow-x-auto"
|
||||
:class="settingsStore.editorSettings.appLayout === 'classic' ? 'h-full' : 'h-10 gap-1.5 py-1.5'"
|
||||
:style="tabsContainerStyle"
|
||||
@scroll="updateScrollButtons"
|
||||
@wheel="onTabsWheel"
|
||||
>
|
||||
<CustomContextMenu
|
||||
v-for="tab in queryStore.tabs"
|
||||
:key="tab.id"
|
||||
:items="getTabMenuItems(tab)"
|
||||
v-slot="{ onContextMenu }"
|
||||
>
|
||||
<div
|
||||
:class="settingsStore.editorSettings.appLayout === 'classic' ? 'h-full' : ''"
|
||||
@contextmenu="onContextMenu"
|
||||
>
|
||||
<div ref="tabsContainerRef" class="app-tab-scroll flex min-w-0 flex-1 items-center overflow-x-auto" :class="settingsStore.editorSettings.appLayout === 'classic' ? 'h-full' : 'h-10 gap-1.5 py-1.5'" :style="tabsContainerStyle" @scroll="updateScrollButtons" @wheel="onTabsWheel">
|
||||
<CustomContextMenu v-for="tab in queryStore.tabs" :key="tab.id" :items="getTabMenuItems(tab)" v-slot="{ onContextMenu }">
|
||||
<div :class="settingsStore.editorSettings.appLayout === 'classic' ? 'h-full' : ''" @contextmenu="onContextMenu">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<div
|
||||
class="group flex items-center gap-1 px-2 text-xs cursor-pointer transition-colors whitespace-nowrap select-none"
|
||||
:class="
|
||||
settingsStore.editorSettings.appLayout === 'classic'
|
||||
? [
|
||||
compactTabTitle ? 'min-w-24' : 'min-w-38',
|
||||
'h-full border-r border-border/80 font-medium dark:border-border/45',
|
||||
tab.id === queryStore.activeTabId && !showDriverStore
|
||||
? 'bg-background text-foreground'
|
||||
: 'text-foreground/70 hover:text-foreground/90',
|
||||
]
|
||||
: [
|
||||
compactTabTitle ? 'min-w-24' : 'min-w-38',
|
||||
'h-7 rounded-md border',
|
||||
tab.id === queryStore.activeTabId && !showDriverStore
|
||||
? 'text-foreground font-medium'
|
||||
: 'border-border/60 text-foreground/70 hover:border-border hover:text-foreground/90',
|
||||
]
|
||||
? [compactTabTitle ? 'min-w-24' : 'min-w-38', 'h-full border-r border-border/80 font-medium dark:border-border/45', tab.id === queryStore.activeTabId && !showDriverStore ? 'bg-background text-foreground' : 'text-foreground/70 hover:text-foreground/90']
|
||||
: [compactTabTitle ? 'min-w-24' : 'min-w-38', 'h-7 rounded-md border', tab.id === queryStore.activeTabId && !showDriverStore ? 'text-foreground font-medium' : 'border-border/60 text-foreground/70 hover:border-border hover:text-foreground/90']
|
||||
"
|
||||
:style="[tabColorStyle(tab), tabDropStyle(tab.id)]"
|
||||
:data-active-tab="tab.id === queryStore.activeTabId && !showDriverStore"
|
||||
|
|
@ -374,20 +309,13 @@ function activateTab(tabId: string) {
|
|||
<span v-else class="min-w-0 truncate flex-1">{{ tabDisplayTitle(tab, t) }}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="inline-flex rounded p-0.5 text-muted-foreground hover:bg-muted-foreground/20 hover:text-foreground focus:opacity-100"
|
||||
:class="tab.pinned ? 'visible text-primary' : 'invisible group-hover:visible'"
|
||||
@click.stop="queryStore.togglePinnedTab(tab.id)"
|
||||
>
|
||||
<button class="inline-flex rounded p-0.5 text-muted-foreground hover:bg-muted-foreground/20 hover:text-foreground focus:opacity-100" :class="tab.pinned ? 'visible text-primary' : 'invisible group-hover:visible'" @click.stop="queryStore.togglePinnedTab(tab.id)">
|
||||
<Pin class="h-3 w-3" :class="{ 'fill-current': tab.pinned }" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ tab.pinned ? t("contextMenu.unpin") : t("contextMenu.pin") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
class="rounded hover:bg-muted-foreground/20 p-0.5 shrink-0"
|
||||
@click.stop="queryStore.closeTab(tab.id)"
|
||||
>
|
||||
<button class="rounded hover:bg-muted-foreground/20 p-0.5 shrink-0" @click.stop="queryStore.closeTab(tab.id)">
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -407,27 +335,15 @@ function activateTab(tabId: string) {
|
|||
v-if="showDriverStore"
|
||||
data-driver-store-tab
|
||||
class="group flex min-w-38 items-center gap-1 px-2 text-xs cursor-pointer transition-colors whitespace-nowrap"
|
||||
:class="
|
||||
settingsStore.editorSettings.appLayout === 'classic'
|
||||
? ['h-full border-r border-border/80 dark:border-border/45 bg-background text-foreground font-medium']
|
||||
: ['h-7 rounded-md border text-foreground font-medium', 'border-ring']
|
||||
"
|
||||
:style="
|
||||
settingsStore.editorSettings.appLayout === 'classic'
|
||||
? { boxShadow: '0 1px 0 0 var(--color-background)' }
|
||||
: {}
|
||||
"
|
||||
:class="settingsStore.editorSettings.appLayout === 'classic' ? ['h-full border-r border-border/80 dark:border-border/45 bg-background text-foreground font-medium'] : ['h-7 rounded-md border text-foreground font-medium', 'border-ring']"
|
||||
:style="settingsStore.editorSettings.appLayout === 'classic' ? { boxShadow: '0 1px 0 0 var(--color-background)' } : {}"
|
||||
@click="emit('toggle-driver-store')"
|
||||
>
|
||||
<span class="shrink-0 text-amber-600 dark:text-amber-400">
|
||||
<Package class="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span class="min-w-0 truncate flex-1">{{ t("toolbar.driverManager") }}</span>
|
||||
<span
|
||||
v-if="(agentDriverUpdateCount ?? 0) > 0"
|
||||
class="inline-flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium leading-none text-white"
|
||||
:aria-label="t('toolbar.updatableDriverCount')"
|
||||
>
|
||||
<span v-if="(agentDriverUpdateCount ?? 0) > 0" class="inline-flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium leading-none text-white" :aria-label="t('toolbar.updatableDriverCount')">
|
||||
{{ (agentDriverUpdateCount ?? 0) > 99 ? "99+" : agentDriverUpdateCount }}
|
||||
</span>
|
||||
<button class="rounded hover:bg-muted-foreground/20 p-0.5 shrink-0" @click.stop="emit('close-driver-store')">
|
||||
|
|
|
|||
|
|
@ -1,26 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
DatabaseZap,
|
||||
FilePlus2,
|
||||
Loader2,
|
||||
Languages,
|
||||
Moon,
|
||||
Sun,
|
||||
SunMoon,
|
||||
History,
|
||||
Bot,
|
||||
ArrowLeftRight,
|
||||
FileCode,
|
||||
FileStack,
|
||||
GitCompareArrows,
|
||||
TableProperties,
|
||||
Settings,
|
||||
CloudDownload,
|
||||
Package,
|
||||
Ellipsis,
|
||||
} from "@lucide/vue";
|
||||
import { DatabaseZap, FilePlus2, Loader2, Languages, Moon, Sun, SunMoon, History, Bot, ArrowLeftRight, FileCode, FileStack, GitCompareArrows, TableProperties, Settings, CloudDownload, Package, Ellipsis } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import LightDropdown from "@/components/ui/LightDropdown.vue";
|
||||
|
|
@ -63,8 +44,7 @@ const emit = defineEmits<{
|
|||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isMac, isDesktop, showControls, isMaximized, isFullscreen, minimize, toggleMaximize, close } =
|
||||
useWindowControls();
|
||||
const { isMac, isDesktop, showControls, isMaximized, isFullscreen, minimize, toggleMaximize, close } = useWindowControls();
|
||||
|
||||
const themeItems = computed(() => [
|
||||
{ value: "light", label: t("toolbar.themeLight"), icon: Sun },
|
||||
|
|
@ -142,10 +122,7 @@ const collapsedItems = computed(() => [
|
|||
},
|
||||
{
|
||||
value: "driver-store",
|
||||
label:
|
||||
props.agentDriverUpdateCount > 0
|
||||
? `${t("toolbar.driverManager")} (${props.agentDriverUpdateCount})`
|
||||
: t("toolbar.driverManager"),
|
||||
label: props.agentDriverUpdateCount > 0 ? `${t("toolbar.driverManager")} (${props.agentDriverUpdateCount})` : t("toolbar.driverManager"),
|
||||
icon: Package,
|
||||
action: () => emit("open-driver-store"),
|
||||
disabled: false,
|
||||
|
|
@ -154,88 +131,42 @@ const collapsedItems = computed(() => [
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="toolbarEl"
|
||||
class="h-10 flex items-center gap-1 px-2 border-b bg-muted/30 shrink-0 overflow-hidden"
|
||||
:class="{ 'pl-17.5': shouldReserveMacTrafficLightInset(isMac, isFullscreen, isDesktop) }"
|
||||
data-tauri-drag-region
|
||||
@dblclick="onToolbarDblClick"
|
||||
>
|
||||
<div ref="toolbarEl" class="h-10 flex items-center gap-1 px-2 border-b bg-muted/30 shrink-0 overflow-hidden" :class="{ 'pl-17.5': shouldReserveMacTrafficLightInset(isMac, isFullscreen, isDesktop) }" data-tauri-drag-region @dblclick="onToolbarDblClick">
|
||||
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs gap-1" @click="emit('new-connection')">
|
||||
<DatabaseZap class="h-3.5 w-3.5" />
|
||||
{{ t("toolbar.newConnection") }}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 px-2 text-xs gap-1"
|
||||
@click="emit('new-query')"
|
||||
:disabled="!hasConnections"
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs gap-1" @click="emit('new-query')" :disabled="!hasConnections">
|
||||
<FilePlus2 class="h-3.5 w-3.5" />
|
||||
{{ t("toolbar.newQuery") }}
|
||||
</Button>
|
||||
|
||||
<template v-if="!toolbarCollapsed">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 px-2 text-xs gap-1"
|
||||
@click="emit('open-transfer')"
|
||||
:disabled="!hasConnections"
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs gap-1" @click="emit('open-transfer')" :disabled="!hasConnections">
|
||||
<ArrowLeftRight class="h-3.5 w-3.5" />
|
||||
{{ t("transfer.dataTransfer") }}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 px-2 text-xs gap-1"
|
||||
@click="emit('open-sql-file')"
|
||||
:disabled="!hasSqlFileConnections"
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs gap-1" @click="emit('open-sql-file')" :disabled="!hasSqlFileConnections">
|
||||
<FileCode class="h-3.5 w-3.5" />
|
||||
{{ t("sqlFile.title") }}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 px-2 text-xs gap-1"
|
||||
@click="emit('open-schema-diff')"
|
||||
:disabled="!hasConnections"
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs gap-1" @click="emit('open-schema-diff')" :disabled="!hasConnections">
|
||||
<GitCompareArrows class="h-3.5 w-3.5" />
|
||||
{{ t("diff.title") }}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 px-2 text-xs gap-1"
|
||||
@click="emit('open-data-compare')"
|
||||
:disabled="!hasConnections"
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs gap-1" @click="emit('open-data-compare')" :disabled="!hasConnections">
|
||||
<TableProperties class="h-3.5 w-3.5" />
|
||||
{{ t("dataCompare.title") }}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 px-2 text-xs gap-1"
|
||||
:class="{ 'bg-accent': showDriverStore }"
|
||||
@click="emit('open-driver-store')"
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs gap-1" :class="{ 'bg-accent': showDriverStore }" @click="emit('open-driver-store')">
|
||||
<Package class="h-3.5 w-3.5" />
|
||||
{{ t("toolbar.driverManager") }}
|
||||
<span
|
||||
v-if="agentDriverUpdateCount > 0"
|
||||
class="ml-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium leading-none text-white"
|
||||
:aria-label="t('toolbar.updatableDriverCount')"
|
||||
>
|
||||
<span v-if="agentDriverUpdateCount > 0" class="ml-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium leading-none text-white" :aria-label="t('toolbar.updatableDriverCount')">
|
||||
{{ agentDriverUpdateCount > 99 ? "99+" : agentDriverUpdateCount }}
|
||||
</span>
|
||||
</Button>
|
||||
|
|
@ -266,19 +197,10 @@ const collapsedItems = computed(() => [
|
|||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="relative h-8 w-8"
|
||||
:disabled="checkingUpdates"
|
||||
@click="emit('check-updates')"
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="relative h-8 w-8" :disabled="checkingUpdates" @click="emit('check-updates')">
|
||||
<Loader2 v-if="checkingUpdates" class="h-4 w-4 animate-spin" />
|
||||
<CloudDownload v-else class="h-4 w-4" />
|
||||
<span
|
||||
v-if="hasUpdateAvailable"
|
||||
class="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-red-500 ring-2 ring-background"
|
||||
/>
|
||||
<span v-if="hasUpdateAvailable" class="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-red-500 ring-2 ring-background" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t("updates.check") }}</TooltipContent>
|
||||
|
|
@ -288,13 +210,7 @@ const collapsedItems = computed(() => [
|
|||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:class="{ 'bg-accent': showSqlLibrary }"
|
||||
@click="emit('toggle-sql-library')"
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" :class="{ 'bg-accent': showSqlLibrary }" @click="emit('toggle-sql-library')">
|
||||
<FileStack class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
|
@ -303,13 +219,7 @@ const collapsedItems = computed(() => [
|
|||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:class="{ 'bg-accent': showHistory }"
|
||||
@click="emit('toggle-history')"
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" :class="{ 'bg-accent': showHistory }" @click="emit('toggle-history')">
|
||||
<History class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
|
@ -318,13 +228,7 @@ const collapsedItems = computed(() => [
|
|||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:class="{ 'bg-accent': showAiPanel }"
|
||||
@click="emit('toggle-ai')"
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" :class="{ 'bg-accent': showAiPanel }" @click="emit('toggle-ai')">
|
||||
<Bot class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
|
@ -396,12 +300,6 @@ const collapsedItems = computed(() => [
|
|||
<TooltipContent>{{ t("settings.title") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<WindowControls
|
||||
v-if="showControls"
|
||||
:is-maximized="isMaximized"
|
||||
@minimize="minimize"
|
||||
@toggle-maximize="toggleMaximize"
|
||||
@close="close"
|
||||
/>
|
||||
<WindowControls v-if="showControls" :is-maximized="isMaximized" @minimize="minimize" @toggle-maximize="toggleMaximize" @close="close" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,24 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref, defineAsyncComponent, watch, nextTick, onMounted, onUnmounted } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
Check,
|
||||
Columns3,
|
||||
Loader2,
|
||||
Search,
|
||||
Square,
|
||||
Bot,
|
||||
Table2,
|
||||
GitBranch,
|
||||
BarChart3,
|
||||
TableProperties,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Inbox,
|
||||
RefreshCcw,
|
||||
Wrench,
|
||||
ListChecks,
|
||||
} from "@lucide/vue";
|
||||
import { Check, Columns3, Loader2, Search, Square, Bot, Table2, GitBranch, BarChart3, TableProperties, ChevronDown, ChevronUp, Inbox, RefreshCcw, Wrench, ListChecks } from "@lucide/vue";
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
import "splitpanes/dist/splitpanes.css";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -153,9 +136,7 @@ const columnInfoError = ref<string | undefined>(undefined);
|
|||
const dataGridRef = ref<DataGridHandle>();
|
||||
const queryEditorRef = ref<InstanceType<typeof QueryEditor>>();
|
||||
const columnVisibilitySearch = ref("");
|
||||
const columnVisibilityOptions = computed(
|
||||
() => dataGridRef.value?.filteredColumnVisibilityOptions(columnVisibilitySearch.value) ?? [],
|
||||
);
|
||||
const columnVisibilityOptions = computed(() => dataGridRef.value?.filteredColumnVisibilityOptions(columnVisibilitySearch.value) ?? []);
|
||||
const redisKeyBrowserRef = ref<SearchableBrowserHandle>();
|
||||
const etcdKeyBrowserRef = ref<SearchableBrowserHandle>();
|
||||
const objectBrowserRef = ref<SearchableBrowserHandle>();
|
||||
|
|
@ -182,8 +163,7 @@ const activeSqlFormatDialect = computed<SqlFormatDialect>(() => {
|
|||
});
|
||||
|
||||
const editorDialect = computed<"mysql" | "postgres" | "sqlserver">(() => {
|
||||
if (activeEffectiveDatabaseType.value === "postgres" || activeEffectiveDatabaseType.value === "kwdb")
|
||||
return "postgres";
|
||||
if (activeEffectiveDatabaseType.value === "postgres" || activeEffectiveDatabaseType.value === "kwdb") return "postgres";
|
||||
if (activeEffectiveDatabaseType.value === "sqlserver") return "sqlserver";
|
||||
return "mysql";
|
||||
});
|
||||
|
|
@ -207,14 +187,7 @@ const activeQueryError = computed(() => {
|
|||
if (!result?.columns.includes("Error")) return "";
|
||||
return String(result.rows[0]?.[0] ?? "");
|
||||
});
|
||||
const hasQueryOutput = computed(
|
||||
() =>
|
||||
!!props.activeTab.result ||
|
||||
!!props.activeTab.explainPlan ||
|
||||
!!props.activeTab.explainError ||
|
||||
props.activeTab.isExecuting === true ||
|
||||
props.activeTab.isExplaining === true,
|
||||
);
|
||||
const hasQueryOutput = computed(() => !!props.activeTab.result || !!props.activeTab.explainPlan || !!props.activeTab.explainError || props.activeTab.isExecuting === true || props.activeTab.isExplaining === true);
|
||||
const tabularResults = computed(() => tabularResultItems(props.activeTab.results));
|
||||
const summaryItems = computed(() => executionSummaryItems(props.activeTab));
|
||||
const hasExecutionSummary = computed(() => summaryItems.value.length > 0 || props.activeTab.isExecuting);
|
||||
|
|
@ -245,11 +218,7 @@ function startQueryRunningElapsedTimer() {
|
|||
|
||||
const queryRunningElapsedSeconds = computed(() => (queryRunningElapsed.value / 1000).toFixed(1));
|
||||
|
||||
watch(
|
||||
() => [props.activeTab.id, props.activeTab.isExecuting, props.activeTab.queryExecutionStartedAt] as const,
|
||||
startQueryRunningElapsedTimer,
|
||||
{ immediate: true },
|
||||
);
|
||||
watch(() => [props.activeTab.id, props.activeTab.isExecuting, props.activeTab.queryExecutionStartedAt] as const, startQueryRunningElapsedTimer, { immediate: true });
|
||||
|
||||
onUnmounted(stopQueryRunningElapsedTimer);
|
||||
|
||||
|
|
@ -329,10 +298,7 @@ watch(
|
|||
);
|
||||
|
||||
// Column info panel handlers
|
||||
async function onHandleClickColumn(
|
||||
matchedCols: Array<{ name: string; table: string; schema?: string }>,
|
||||
errorMsg?: string,
|
||||
) {
|
||||
async function onHandleClickColumn(matchedCols: Array<{ name: string; table: string; schema?: string }>, errorMsg?: string) {
|
||||
if (!props.activeTab.connectionId || !props.activeTab.database) return;
|
||||
|
||||
// If error or no columns, silently ignore — don't show the panel
|
||||
|
|
@ -349,12 +315,7 @@ async function onHandleClickColumn(
|
|||
for (const matchedCol of matchedCols) {
|
||||
const querySchema = matchedCol.schema || props.activeTab.database || "";
|
||||
try {
|
||||
const fullColumns = await apiModule.getColumns(
|
||||
props.activeTab.connectionId,
|
||||
props.activeTab.database,
|
||||
querySchema,
|
||||
matchedCol.table,
|
||||
);
|
||||
const fullColumns = await apiModule.getColumns(props.activeTab.connectionId, props.activeTab.database, querySchema, matchedCol.table);
|
||||
for (const col of fullColumns) {
|
||||
if (col.name === matchedCol.name) {
|
||||
results.push({
|
||||
|
|
@ -462,20 +423,8 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
|
|||
@click-column="onHandleClickColumn"
|
||||
@close-column-panel="onHandleCloseColumnPanel"
|
||||
/>
|
||||
<ColumnInfoPanel
|
||||
v-if="showColumnInfo"
|
||||
:columns="columnInfoColumns"
|
||||
:loading="columnInfoLoading"
|
||||
:error="columnInfoError"
|
||||
@close="closeColumnInfo"
|
||||
/>
|
||||
<Button
|
||||
v-if="hasQueryOutput && !resultsPaneOpen"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="absolute bottom-3 right-3 z-20 h-7 gap-1.5 rounded-full border bg-background/95 px-3 text-xs shadow-lg backdrop-blur hover:bg-accent"
|
||||
@click="resultsPaneOpen = true"
|
||||
>
|
||||
<ColumnInfoPanel v-if="showColumnInfo" :columns="columnInfoColumns" :loading="columnInfoLoading" :error="columnInfoError" @close="closeColumnInfo" />
|
||||
<Button v-if="hasQueryOutput && !resultsPaneOpen" variant="secondary" size="sm" class="absolute bottom-3 right-3 z-20 h-7 gap-1.5 rounded-full border bg-background/95 px-3 text-xs shadow-lg backdrop-blur hover:bg-accent" @click="resultsPaneOpen = true">
|
||||
<ChevronUp class="h-3.5 w-3.5" />
|
||||
{{ t("editor.showResultsPane") }}
|
||||
</Button>
|
||||
|
|
@ -483,18 +432,8 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
|
|||
</Pane>
|
||||
<Pane v-if="resultsPaneOpen" :size="60" :min-size="20">
|
||||
<div class="h-full flex flex-col">
|
||||
<div
|
||||
v-if="hasQueryOutput"
|
||||
class="h-8 shrink-0 border-b bg-muted/20 px-2 flex items-center gap-1 overflow-x-auto"
|
||||
style="scrollbar-width: none; -ms-overflow-style: none; -webkit-overflow-scrolling: touch"
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="activeOutputView === 'result' ? 'secondary' : 'ghost'"
|
||||
class="h-6 px-2 text-xs"
|
||||
:disabled="!hasTabularResult && !activeTab.isExecuting"
|
||||
@click="emit('update:activeOutputView', 'result')"
|
||||
>
|
||||
<div v-if="hasQueryOutput" class="h-8 shrink-0 border-b bg-muted/20 px-2 flex items-center gap-1 overflow-x-auto" style="scrollbar-width: none; -ms-overflow-style: none; -webkit-overflow-scrolling: touch">
|
||||
<Button size="sm" :variant="activeOutputView === 'result' ? 'secondary' : 'ghost'" class="h-6 px-2 text-xs" :disabled="!hasTabularResult && !activeTab.isExecuting" @click="emit('update:activeOutputView', 'result')">
|
||||
{{ t("tabs.tableData") }}
|
||||
</Button>
|
||||
<template v-if="tabularResults.length > 1">
|
||||
|
|
@ -503,9 +442,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
|
|||
v-for="item in tabularResults"
|
||||
:key="item.index"
|
||||
size="sm"
|
||||
:variant="
|
||||
activeOutputView === 'result' && activeTab.activeResultIndex === item.index ? 'default' : 'ghost'
|
||||
"
|
||||
:variant="activeOutputView === 'result' && activeTab.activeResultIndex === item.index ? 'default' : 'ghost'"
|
||||
class="h-6 px-2 text-xs shrink-0"
|
||||
@click="
|
||||
queryStore.setActiveResultIndex(activeTab.id, item.index);
|
||||
|
|
@ -515,34 +452,16 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
|
|||
{{ t("tabs.resultN", { n: item.n }) }}
|
||||
</Button>
|
||||
</template>
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="activeOutputView === 'summary' ? 'secondary' : 'ghost'"
|
||||
class="h-6 px-2 text-xs gap-1"
|
||||
:disabled="!hasExecutionSummary"
|
||||
@click="emit('update:activeOutputView', 'summary')"
|
||||
>
|
||||
<Button size="sm" :variant="activeOutputView === 'summary' ? 'secondary' : 'ghost'" class="h-6 px-2 text-xs gap-1" :disabled="!hasExecutionSummary" @click="emit('update:activeOutputView', 'summary')">
|
||||
<ListChecks class="h-3.5 w-3.5" />
|
||||
{{ t("tabs.executionSummary") }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="activeOutputView === 'chart' ? 'secondary' : 'ghost'"
|
||||
class="h-6 px-2 text-xs gap-1"
|
||||
:disabled="!hasNumericData"
|
||||
@click="emit('update:activeOutputView', 'chart')"
|
||||
>
|
||||
<Button size="sm" :variant="activeOutputView === 'chart' ? 'secondary' : 'ghost'" class="h-6 px-2 text-xs gap-1" :disabled="!hasNumericData" @click="emit('update:activeOutputView', 'chart')">
|
||||
<BarChart3 class="h-3.5 w-3.5" />
|
||||
{{ t("chart.title") }}
|
||||
</Button>
|
||||
<span class="mx-1 h-4 w-px shrink-0 bg-border" />
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="activeOutputView === 'explain' ? 'secondary' : 'ghost'"
|
||||
class="h-6 px-2 text-xs gap-1"
|
||||
:disabled="!activeTab.explainPlan && !activeTab.explainError && !activeTab.isExplaining"
|
||||
@click="emit('update:activeOutputView', 'explain')"
|
||||
>
|
||||
<Button size="sm" :variant="activeOutputView === 'explain' ? 'secondary' : 'ghost'" class="h-6 px-2 text-xs gap-1" :disabled="!activeTab.explainPlan && !activeTab.explainError && !activeTab.isExplaining" @click="emit('update:activeOutputView', 'explain')">
|
||||
<GitBranch class="h-3.5 w-3.5" />
|
||||
{{ t("explain.title") }}
|
||||
</Button>
|
||||
|
|
@ -561,152 +480,70 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
|
|||
<Wrench class="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
class="w-max min-w-44 max-w-[calc(100vw-2rem)] gap-0 overflow-hidden rounded-xl border bg-popover p-0 text-popover-foreground shadow-xl"
|
||||
@click.stop
|
||||
@keydown.stop
|
||||
>
|
||||
<PopoverContent align="end" class="w-max min-w-44 max-w-[calc(100vw-2rem)] gap-0 overflow-hidden rounded-xl border bg-popover p-0 text-popover-foreground shadow-xl" @click.stop @keydown.stop>
|
||||
<div class="border-b bg-muted/40 px-3 py-2">
|
||||
<div class="text-xs font-semibold">{{ t("grid.viewOptions") }}</div>
|
||||
</div>
|
||||
<LightTooltip
|
||||
:text="t('grid.transposeMultiRowHint')"
|
||||
side="left"
|
||||
:side-offset="6"
|
||||
:delay="0"
|
||||
:open-on-focus="false"
|
||||
>
|
||||
<label
|
||||
class="flex cursor-pointer items-center justify-between gap-3 px-3 py-2 text-xs hover:bg-accent"
|
||||
>
|
||||
<LightTooltip :text="t('grid.transposeMultiRowHint')" side="left" :side-offset="6" :delay="0" :open-on-focus="false">
|
||||
<label class="flex cursor-pointer items-center justify-between gap-3 px-3 py-2 text-xs hover:bg-accent">
|
||||
<span class="min-w-0 flex items-center gap-1.5 font-medium">
|
||||
{{ t("grid.transposeMultiRowToggle") }}
|
||||
<span class="text-muted-foreground">
|
||||
{{
|
||||
dataGridRef?.multiRowTranspose ? t("grid.transposeMultiRow") : t("grid.transposeSingleRow")
|
||||
}}
|
||||
{{ dataGridRef?.multiRowTranspose ? t("grid.transposeMultiRow") : t("grid.transposeSingleRow") }}
|
||||
</span>
|
||||
</span>
|
||||
<Switch
|
||||
size="sm"
|
||||
:model-value="!!dataGridRef?.multiRowTranspose"
|
||||
:aria-label="t('grid.transposeMultiRow')"
|
||||
@update:model-value="(value: boolean) => dataGridRef?.setMultiRowTranspose(value)"
|
||||
/>
|
||||
<Switch size="sm" :model-value="!!dataGridRef?.multiRowTranspose" :aria-label="t('grid.transposeMultiRow')" @update:model-value="(value: boolean) => dataGridRef?.setMultiRowTranspose(value)" />
|
||||
</label>
|
||||
</LightTooltip>
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-2 px-3 py-2 text-xs hover:bg-accent"
|
||||
:class="{ 'cursor-not-allowed opacity-60': !dataGridRef?.canToggleAllNullColumns }"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-3.5 w-3.5 shrink-0 accent-primary"
|
||||
:checked="!!dataGridRef?.nullColumnsHidden"
|
||||
:disabled="!dataGridRef?.canToggleAllNullColumns"
|
||||
@change="dataGridRef?.toggleAllNullColumns()"
|
||||
/>
|
||||
<label class="flex cursor-pointer items-center gap-2 px-3 py-2 text-xs hover:bg-accent" :class="{ 'cursor-not-allowed opacity-60': !dataGridRef?.canToggleAllNullColumns }">
|
||||
<input type="checkbox" class="h-3.5 w-3.5 shrink-0 accent-primary" :checked="!!dataGridRef?.nullColumnsHidden" :disabled="!dataGridRef?.canToggleAllNullColumns" @change="dataGridRef?.toggleAllNullColumns()" />
|
||||
<span class="min-w-0 flex items-center gap-1 font-medium">
|
||||
{{ t("grid.hideNullColumns") }}
|
||||
<span
|
||||
v-if="(dataGridRef?.allNullColumnCount ?? 0) > 0"
|
||||
class="text-muted-foreground tabular-nums"
|
||||
>
|
||||
({{ dataGridRef?.allNullColumnCount }})
|
||||
</span>
|
||||
<span v-if="(dataGridRef?.allNullColumnCount ?? 0) > 0" class="text-muted-foreground tabular-nums"> ({{ dataGridRef?.allNullColumnCount }}) </span>
|
||||
</span>
|
||||
</label>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button
|
||||
v-if="activeOutputView === 'result' && hasTabularResult"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 shrink-0 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
:disabled="activeTab.isExecuting"
|
||||
@click="refreshData"
|
||||
>
|
||||
<Button v-if="activeOutputView === 'result' && hasTabularResult" variant="ghost" size="sm" class="h-6 shrink-0 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground" :disabled="activeTab.isExecuting" @click="refreshData">
|
||||
<Loader2 v-if="activeTab.isExecuting" class="h-3.5 w-3.5 animate-spin" />
|
||||
<RefreshCcw v-else class="h-3.5 w-3.5" />
|
||||
{{ t("grid.refresh") }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 shrink-0 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
:class="{ 'ml-auto': activeOutputView !== 'result' || !hasTabularResult }"
|
||||
@click="resultsPaneOpen = false"
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="h-6 shrink-0 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground" :class="{ 'ml-auto': activeOutputView !== 'result' || !hasTabularResult }" @click="resultsPaneOpen = false">
|
||||
<ChevronDown class="h-3.5 w-3.5" />
|
||||
{{ t("editor.hideResultsPane") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ExplainPlanViewer
|
||||
v-if="activeOutputView === 'explain'"
|
||||
class="flex-1 min-h-0"
|
||||
:plan="activeTab.explainPlan"
|
||||
:error="activeTab.explainError"
|
||||
:loading="activeTab.isExplaining"
|
||||
:source-sql="activeTab.lastExplainedSql"
|
||||
:explain-sql="activeTab.explainSql"
|
||||
/>
|
||||
<ExplainPlanViewer v-if="activeOutputView === 'explain'" class="flex-1 min-h-0" :plan="activeTab.explainPlan" :error="activeTab.explainError" :loading="activeTab.isExplaining" :source-sql="activeTab.lastExplainedSql" :explain-sql="activeTab.explainSql" />
|
||||
|
||||
<QueryChart
|
||||
v-else-if="activeOutputView === 'chart' && activeTab.result"
|
||||
class="flex-1 min-h-0"
|
||||
:result="activeTab.result"
|
||||
/>
|
||||
<QueryChart v-else-if="activeOutputView === 'chart' && activeTab.result" class="flex-1 min-h-0" :result="activeTab.result" />
|
||||
|
||||
<div v-else-if="activeOutputView === 'summary'" class="flex-1 min-h-0 overflow-auto bg-background">
|
||||
<div
|
||||
v-if="activeTab.isExecuting"
|
||||
class="flex h-full items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
<div v-if="activeTab.isExecuting" class="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||
{{ t("executionSummary.executing") }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="summaryItems.length === 0"
|
||||
class="flex h-full items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
<div v-else-if="summaryItems.length === 0" class="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{{ t("executionSummary.empty") }}
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="overflow-hidden border-b">
|
||||
<div
|
||||
class="grid grid-cols-[4rem_1fr_8rem_8rem_7rem] border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
<div class="grid grid-cols-[4rem_1fr_8rem_8rem_7rem] border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<div>{{ t("executionSummary.statement") }}</div>
|
||||
<div>{{ t("executionSummary.type") }}</div>
|
||||
<div class="text-right">{{ t("executionSummary.rows") }}</div>
|
||||
<div class="text-right">{{ t("executionSummary.affected") }}</div>
|
||||
<div class="text-right">{{ t("executionSummary.time") }}</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="item in summaryItems"
|
||||
:key="item.index"
|
||||
class="grid grid-cols-[4rem_1fr_8rem_8rem_7rem] items-center border-b px-3 py-2 text-xs last:border-b-0"
|
||||
>
|
||||
<div v-for="item in summaryItems" :key="item.index" class="grid grid-cols-[4rem_1fr_8rem_8rem_7rem] items-center border-b px-3 py-2 text-xs last:border-b-0">
|
||||
<div class="font-mono text-muted-foreground">#{{ item.index + 1 }}</div>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
class="inline-flex h-5 items-center rounded-full border px-2 text-[10px]"
|
||||
:class="
|
||||
item.isError
|
||||
? 'border-destructive/40 bg-destructive/10 text-destructive'
|
||||
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
|
||||
"
|
||||
>
|
||||
<span class="inline-flex h-5 items-center rounded-full border px-2 text-[10px]" :class="item.isError ? 'border-destructive/40 bg-destructive/10 text-destructive' : 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'">
|
||||
{{ item.isError ? t("executionSummary.error") : t("executionSummary.success") }}
|
||||
</span>
|
||||
<span class="truncate">
|
||||
{{
|
||||
item.hasTabularResult
|
||||
? t("executionSummary.returnedTable", { count: item.returnedColumns })
|
||||
: t("executionSummary.noTable")
|
||||
}}
|
||||
{{ item.hasTabularResult ? t("executionSummary.returnedTable", { count: item.returnedColumns }) : t("executionSummary.noTable") }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-right tabular-nums">{{ item.returnedRows.toLocaleString() }}</div>
|
||||
|
|
@ -747,51 +584,25 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
|
|||
:on-execute-sql="async (sql: string) => emit('executeSql', sql)"
|
||||
:full-export-result="() => queryStore.fetchTabResultForExport(activeTab.id)"
|
||||
@update:order-by-input="(v: string) => (activeTab.orderByInput = v)"
|
||||
@reload="
|
||||
(
|
||||
sql?: string,
|
||||
searchText?: string,
|
||||
whereInput?: string,
|
||||
orderBy?: string,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
) => emit('reload', sql, searchText, whereInput, orderBy, limit, offset)
|
||||
"
|
||||
@paginate="
|
||||
(offset: number, limit: number, whereInput?: string, orderBy?: string) =>
|
||||
emit('paginate', offset, limit, whereInput, orderBy)
|
||||
"
|
||||
@sort="
|
||||
(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string) =>
|
||||
emit('sort', column, columnIndex, direction, whereInput)
|
||||
"
|
||||
@reload="(sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number) => emit('reload', sql, searchText, whereInput, orderBy, limit, offset)"
|
||||
@paginate="(offset: number, limit: number, whereInput?: string, orderBy?: string) => emit('paginate', offset, limit, whereInput, orderBy)"
|
||||
@sort="(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string) => emit('sort', column, columnIndex, direction, whereInput)"
|
||||
>
|
||||
<template v-if="activeTab.result?.columns.includes('Error')" #error-actions="{ errorMessage }">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="mt-2 h-7 gap-1.5 border-destructive/30 bg-background px-2.5 text-xs text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
@click="emit('fixWithAi', String(errorMessage))"
|
||||
>
|
||||
<Button variant="outline" size="sm" class="mt-2 h-7 gap-1.5 border-destructive/30 bg-background px-2.5 text-xs text-destructive hover:bg-destructive/10 hover:text-destructive" @click="emit('fixWithAi', String(errorMessage))">
|
||||
<Bot class="h-3.5 w-3.5" />
|
||||
{{ t("ai.fixWithAi") }}
|
||||
</Button>
|
||||
</template>
|
||||
</DataGrid>
|
||||
<div
|
||||
v-else-if="!activeTab.result && activeTab.isExecuting"
|
||||
class="flex-1 min-h-0 flex flex-col items-center justify-center gap-3 text-muted-foreground text-sm"
|
||||
>
|
||||
<div v-else-if="!activeTab.result && activeTab.isExecuting" class="flex-1 min-h-0 flex flex-col items-center justify-center gap-3 text-muted-foreground text-sm">
|
||||
<div class="flex items-center">
|
||||
<Loader2 class="h-5 w-5 animate-spin mr-2" />
|
||||
{{ t(queryExecutionLabelKey(activeTab)) }}
|
||||
<span class="ml-1 tabular-nums text-muted-foreground/80">· {{ queryRunningElapsedSeconds }}s</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!activeTab.result"
|
||||
class="flex-1 min-h-0 flex flex-col items-center justify-center gap-1 text-muted-foreground text-sm"
|
||||
>
|
||||
<div v-else-if="!activeTab.result" class="flex-1 min-h-0 flex flex-col items-center justify-center gap-1 text-muted-foreground text-sm">
|
||||
<div>{{ t("editor.pressToExecute", { mod: shortcutModifier }) }}</div>
|
||||
<div>{{ t("editor.pressToSaveSql", { mod: shortcutModifier }) }}</div>
|
||||
</div>
|
||||
|
|
@ -805,128 +616,61 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
|
|||
<template v-else-if="activeTab.mode === 'data'">
|
||||
<div class="flex-1 min-h-0 flex flex-col">
|
||||
<div class="h-9 shrink-0 border-b bg-background/80 px-3 flex items-center gap-2 text-xs">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded border border-emerald-200 bg-emerald-50 px-2 py-0.5 font-medium text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-300"
|
||||
>
|
||||
<span class="inline-flex items-center gap-1 rounded border border-emerald-200 bg-emerald-50 px-2 py-0.5 font-medium text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-300">
|
||||
<Table2 class="h-3.5 w-3.5" />
|
||||
{{ t("tabs.tableData") }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center rounded border border-border bg-muted/50 px-2 py-0.5 font-medium truncate"
|
||||
>
|
||||
<span class="inline-flex items-center rounded border border-border bg-muted/50 px-2 py-0.5 font-medium truncate">
|
||||
{{ activeTab.tableMeta?.tableName || activeTab.title }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center rounded border border-border bg-muted/30 px-2 py-0.5 text-muted-foreground truncate"
|
||||
>
|
||||
<template v-if="activeTab.tableMeta?.schema">{{ activeTab.tableMeta.schema }}@</template
|
||||
>{{ databaseDisplayNameForTab(activeTab.connectionId, activeTab.database, t) }}
|
||||
</span>
|
||||
<span v-if="activeTab.tableMeta" class="ml-auto text-muted-foreground">
|
||||
{{ activeTab.tableMeta.columns.length }} {{ t("tree.columns") }}
|
||||
<span class="inline-flex items-center rounded border border-border bg-muted/30 px-2 py-0.5 text-muted-foreground truncate">
|
||||
<template v-if="activeTab.tableMeta?.schema">{{ activeTab.tableMeta.schema }}@</template>{{ databaseDisplayNameForTab(activeTab.connectionId, activeTab.database, t) }}
|
||||
</span>
|
||||
<span v-if="activeTab.tableMeta" class="ml-auto text-muted-foreground"> {{ activeTab.tableMeta.columns.length }} {{ t("tree.columns") }} </span>
|
||||
<Popover v-if="activeTab.result?.columns.length">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-5 text-xs px-1.5 shrink-0"
|
||||
:class="{ 'bg-accent text-foreground': (dataGridRef?.hiddenColumnCount ?? 0) > 0 }"
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="h-5 text-xs px-1.5 shrink-0" :class="{ 'bg-accent text-foreground': (dataGridRef?.hiddenColumnCount ?? 0) > 0 }">
|
||||
<Columns3 class="h-3.5 w-3.5" />
|
||||
{{ t("grid.columnVisibility") }}
|
||||
<span v-if="(dataGridRef?.hiddenColumnCount ?? 0) > 0" class="tabular-nums">
|
||||
{{ dataGridRef?.visibleColumnCount }}/{{ dataGridRef?.displayableColumnCount }}
|
||||
</span>
|
||||
<span v-if="(dataGridRef?.hiddenColumnCount ?? 0) > 0" class="tabular-nums"> {{ dataGridRef?.visibleColumnCount }}/{{ dataGridRef?.displayableColumnCount }} </span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
class="w-64 max-w-[calc(100vw-2rem)] gap-0 overflow-hidden rounded-xl border bg-popover p-0 text-popover-foreground shadow-xl"
|
||||
@click.stop
|
||||
@keydown.stop
|
||||
>
|
||||
<PopoverContent align="end" class="w-64 max-w-[calc(100vw-2rem)] gap-0 overflow-hidden rounded-xl border bg-popover p-0 text-popover-foreground shadow-xl" @click.stop @keydown.stop>
|
||||
<div class="border-b bg-muted/40 px-2 py-1.5">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="text-xs font-semibold">{{ t("grid.columnVisibility") }}</div>
|
||||
<div class="text-[10px] text-muted-foreground tabular-nums">
|
||||
{{ dataGridRef?.visibleColumnCount ?? 0 }}/{{ dataGridRef?.displayableColumnCount ?? 0 }}
|
||||
</div>
|
||||
<div class="text-[10px] text-muted-foreground tabular-nums">{{ dataGridRef?.visibleColumnCount ?? 0 }}/{{ dataGridRef?.displayableColumnCount ?? 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 border-b px-2 py-1.5">
|
||||
<Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
v-model="columnVisibilitySearch"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="h-6 min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground"
|
||||
:placeholder="t('grid.searchColumns')"
|
||||
/>
|
||||
<input v-model="columnVisibilitySearch" autocapitalize="off" autocorrect="off" spellcheck="false" class="h-6 min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground" :placeholder="t('grid.searchColumns')" />
|
||||
</div>
|
||||
<div class="max-h-72 overflow-auto py-0.5">
|
||||
<button
|
||||
v-for="option in columnVisibilityOptions"
|
||||
:key="`${option.index}:${option.column}`"
|
||||
type="button"
|
||||
class="grid w-full grid-cols-[1.5rem_minmax(0,1fr)] items-center px-2 py-1 text-left text-xs hover:bg-accent"
|
||||
@click="dataGridRef?.toggleColumnVisibility(option.index)"
|
||||
>
|
||||
<span
|
||||
class="flex h-4 w-4 items-center justify-center rounded border"
|
||||
:class="
|
||||
dataGridRef?.isColumnVisible(option.index)
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-border bg-background text-transparent'
|
||||
"
|
||||
>
|
||||
<button v-for="option in columnVisibilityOptions" :key="`${option.index}:${option.column}`" type="button" class="grid w-full grid-cols-[1.5rem_minmax(0,1fr)] items-center px-2 py-1 text-left text-xs hover:bg-accent" @click="dataGridRef?.toggleColumnVisibility(option.index)">
|
||||
<span class="flex h-4 w-4 items-center justify-center rounded border" :class="dataGridRef?.isColumnVisible(option.index) ? 'border-primary bg-primary text-primary-foreground' : 'border-border bg-background text-transparent'">
|
||||
<Check class="h-3 w-3 stroke-[3]" />
|
||||
</span>
|
||||
<span class="truncate font-mono text-xs" :title="option.column">{{ option.column }}</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="columnVisibilityOptions.length === 0"
|
||||
class="px-2 py-6 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
<div v-if="columnVisibilityOptions.length === 0" class="px-2 py-6 text-center text-xs text-muted-foreground">
|
||||
{{ t("grid.noSearchResults") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2 border-t bg-muted/30 px-2 py-1.5">
|
||||
<span class="text-[11px] text-muted-foreground">{{ t("grid.columnVisibilityHint") }}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-xs"
|
||||
:disabled="(dataGridRef?.displayableColumnCount ?? 0) <= 1"
|
||||
@click="dataGridRef?.invertColumnVisibility()"
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="(dataGridRef?.displayableColumnCount ?? 0) <= 1" @click="dataGridRef?.invertColumnVisibility()">
|
||||
{{ t("grid.invertColumnVisibility") }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-xs"
|
||||
:disabled="(dataGridRef?.hiddenColumnCount ?? 0) === 0"
|
||||
@click="dataGridRef?.showAllColumns()"
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="(dataGridRef?.hiddenColumnCount ?? 0) === 0" @click="dataGridRef?.showAllColumns()">
|
||||
{{ t("grid.showAllColumns") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button
|
||||
v-if="activeTab.tableMeta && activeTab.connectionId"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-5 text-xs px-1.5 shrink-0"
|
||||
:class="{ 'bg-accent': dataGridRef?.showDdl }"
|
||||
@click="dataGridRef?.toggleDdl()"
|
||||
>
|
||||
<TableProperties class="h-3.5 w-3.5" /> {{ t("grid.tableInfo") }}
|
||||
</Button>
|
||||
<Button v-if="activeTab.tableMeta && activeTab.connectionId" variant="ghost" size="sm" class="h-5 text-xs px-1.5 shrink-0" :class="{ 'bg-accent': dataGridRef?.showDdl }" @click="dataGridRef?.toggleDdl()"> <TableProperties class="h-3.5 w-3.5" /> {{ t("grid.tableInfo") }} </Button>
|
||||
<Popover v-if="activeTab.result?.columns.length">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
|
|
@ -942,22 +686,11 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
|
|||
<Wrench class="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
class="w-max min-w-44 max-w-[calc(100vw-2rem)] gap-0 overflow-hidden rounded-xl border bg-popover p-0 text-popover-foreground shadow-xl"
|
||||
@click.stop
|
||||
@keydown.stop
|
||||
>
|
||||
<PopoverContent align="end" class="w-max min-w-44 max-w-[calc(100vw-2rem)] gap-0 overflow-hidden rounded-xl border bg-popover p-0 text-popover-foreground shadow-xl" @click.stop @keydown.stop>
|
||||
<div class="border-b bg-muted/40 px-3 py-2">
|
||||
<div class="text-xs font-semibold">{{ t("grid.viewOptions") }}</div>
|
||||
</div>
|
||||
<LightTooltip
|
||||
:text="t('grid.transposeMultiRowHint')"
|
||||
side="left"
|
||||
:side-offset="6"
|
||||
:delay="0"
|
||||
:open-on-focus="false"
|
||||
>
|
||||
<LightTooltip :text="t('grid.transposeMultiRowHint')" side="left" :side-offset="6" :delay="0" :open-on-focus="false">
|
||||
<label class="flex cursor-pointer items-center justify-between gap-3 px-3 py-2 text-xs hover:bg-accent">
|
||||
<span class="min-w-0 flex items-center gap-1.5 font-medium">
|
||||
{{ t("grid.transposeMultiRowToggle") }}
|
||||
|
|
@ -965,30 +698,14 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
|
|||
{{ dataGridRef?.multiRowTranspose ? t("grid.transposeMultiRow") : t("grid.transposeSingleRow") }}
|
||||
</span>
|
||||
</span>
|
||||
<Switch
|
||||
size="sm"
|
||||
:model-value="!!dataGridRef?.multiRowTranspose"
|
||||
:aria-label="t('grid.transposeMultiRow')"
|
||||
@update:model-value="(value: boolean) => dataGridRef?.setMultiRowTranspose(value)"
|
||||
/>
|
||||
<Switch size="sm" :model-value="!!dataGridRef?.multiRowTranspose" :aria-label="t('grid.transposeMultiRow')" @update:model-value="(value: boolean) => dataGridRef?.setMultiRowTranspose(value)" />
|
||||
</label>
|
||||
</LightTooltip>
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-2 px-3 py-2 text-xs hover:bg-accent"
|
||||
:class="{ 'cursor-not-allowed opacity-60': !dataGridRef?.canToggleAllNullColumns }"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-3.5 w-3.5 shrink-0 accent-primary"
|
||||
:checked="!!dataGridRef?.nullColumnsHidden"
|
||||
:disabled="!dataGridRef?.canToggleAllNullColumns"
|
||||
@change="dataGridRef?.toggleAllNullColumns()"
|
||||
/>
|
||||
<label class="flex cursor-pointer items-center gap-2 px-3 py-2 text-xs hover:bg-accent" :class="{ 'cursor-not-allowed opacity-60': !dataGridRef?.canToggleAllNullColumns }">
|
||||
<input type="checkbox" class="h-3.5 w-3.5 shrink-0 accent-primary" :checked="!!dataGridRef?.nullColumnsHidden" :disabled="!dataGridRef?.canToggleAllNullColumns" @change="dataGridRef?.toggleAllNullColumns()" />
|
||||
<span class="min-w-0 flex items-center gap-1 font-medium">
|
||||
{{ t("grid.hideNullColumns") }}
|
||||
<span v-if="(dataGridRef?.allNullColumnCount ?? 0) > 0" class="text-muted-foreground tabular-nums">
|
||||
({{ dataGridRef?.allNullColumnCount }})
|
||||
</span>
|
||||
<span v-if="(dataGridRef?.allNullColumnCount ?? 0) > 0" class="text-muted-foreground tabular-nums"> ({{ dataGridRef?.allNullColumnCount }}) </span>
|
||||
</span>
|
||||
</label>
|
||||
</PopoverContent>
|
||||
|
|
@ -1020,41 +737,17 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
|
|||
:full-export-result="() => queryStore.fetchTabResultForExport(activeTab.id)"
|
||||
@update:where-input="(v: string) => (activeTab.whereInput = v)"
|
||||
@update:order-by-input="(v: string) => (activeTab.orderByInput = v)"
|
||||
@reload="
|
||||
(
|
||||
sql?: string,
|
||||
searchText?: string,
|
||||
whereInput?: string,
|
||||
orderBy?: string,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
) => emit('reload', sql, searchText, whereInput, orderBy, limit, offset)
|
||||
"
|
||||
@paginate="
|
||||
(offset: number, limit: number, whereInput?: string, orderBy?: string) =>
|
||||
emit('paginate', offset, limit, whereInput, orderBy)
|
||||
"
|
||||
@sort="
|
||||
(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string) =>
|
||||
emit('sort', column, columnIndex, direction, whereInput)
|
||||
"
|
||||
@reload="(sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number) => emit('reload', sql, searchText, whereInput, orderBy, limit, offset)"
|
||||
@paginate="(offset: number, limit: number, whereInput?: string, orderBy?: string) => emit('paginate', offset, limit, whereInput, orderBy)"
|
||||
@sort="(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string) => emit('sort', column, columnIndex, direction, whereInput)"
|
||||
/>
|
||||
<div
|
||||
v-else-if="activeTab.isExecuting"
|
||||
class="h-full flex flex-col items-center justify-center gap-3 text-muted-foreground text-sm"
|
||||
>
|
||||
<div v-else-if="activeTab.isExecuting" class="h-full flex flex-col items-center justify-center gap-3 text-muted-foreground text-sm">
|
||||
<div class="flex items-center">
|
||||
<Loader2 class="h-5 w-5 animate-spin mr-2" />
|
||||
{{ t(queryExecutionLabelKey(activeTab)) }}
|
||||
<span class="ml-1 tabular-nums text-muted-foreground/80">· {{ queryRunningElapsedSeconds }}s</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
class="h-7 gap-1.5"
|
||||
:disabled="!canCancelQueryExecution(activeTab)"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
<Button variant="destructive" size="sm" class="h-7 gap-1.5" :disabled="!canCancelQueryExecution(activeTab)" @click="emit('cancel')">
|
||||
<Loader2 v-if="activeTab.isCancelling" class="h-3.5 w-3.5 animate-spin" />
|
||||
<Square v-else class="h-3.5 w-3.5 fill-current" />
|
||||
{{ t("toolbar.stopQuery") }}
|
||||
|
|
@ -1065,12 +758,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
|
|||
<div>{{ t("grid.dataUnavailable") }}</div>
|
||||
<div class="text-xs text-muted-foreground/70 inline-flex items-center gap-1">
|
||||
<span>{{ t("grid.dataUnavailableHintPrefix") }}</span>
|
||||
<kbd
|
||||
v-for="key in modRKeys"
|
||||
:key="key"
|
||||
class="min-w-5 rounded border border-border/60 bg-muted/50 px-1.5 py-0.5 text-center font-mono text-[12px] leading-none text-muted-foreground shadow-xs"
|
||||
>{{ key }}</kbd
|
||||
>
|
||||
<kbd v-for="key in modRKeys" :key="key" class="min-w-5 rounded border border-border/60 bg-muted/50 px-1.5 py-0.5 text-center font-mono text-[12px] leading-none text-muted-foreground shadow-xs">{{ key }}</kbd>
|
||||
<span>{{ t("grid.dataUnavailableHintSuffix") }}</span>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" class="h-7 gap-1.5" @click="emit('reload')">
|
||||
|
|
@ -1084,12 +772,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
|
|||
<!-- Redis mode: key browser -->
|
||||
<template v-else-if="activeTab.mode === 'redis'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<RedisKeyBrowser
|
||||
ref="redisKeyBrowserRef"
|
||||
:key="activeTab.id"
|
||||
:connection-id="activeTab.connectionId"
|
||||
:db="Number(activeTab.database)"
|
||||
/>
|
||||
<RedisKeyBrowser ref="redisKeyBrowserRef" :key="activeTab.id" :connection-id="activeTab.connectionId" :db="Number(activeTab.database)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -1103,12 +786,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
|
|||
<!-- MongoDB mode: document browser -->
|
||||
<template v-else-if="activeTab.mode === 'mongo'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<MongoDocBrowser
|
||||
:key="activeTab.id"
|
||||
:connection-id="activeTab.connectionId"
|
||||
:database="activeTab.database"
|
||||
:collection="activeTab.sql"
|
||||
/>
|
||||
<MongoDocBrowser :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" :collection="activeTab.sql" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, watchEffect } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
Play,
|
||||
Loader2,
|
||||
Square,
|
||||
Database,
|
||||
Check,
|
||||
Table2,
|
||||
AlignLeft,
|
||||
GitBranch,
|
||||
Save,
|
||||
FolderOpen,
|
||||
Layers,
|
||||
} from "@lucide/vue";
|
||||
import { Play, Loader2, Square, Database, Check, Table2, AlignLeft, GitBranch, Save, FolderOpen, Layers } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
|
|
@ -73,11 +61,7 @@ const saveTooltip = computed(() => (props.activeTab.objectSource ? t("objects.sa
|
|||
|
||||
const showSchemaSelector = computed(() => {
|
||||
const connection = props.activeConnection;
|
||||
return (
|
||||
connection &&
|
||||
isSchemaAware(connection.id) &&
|
||||
(props.activeTab.database || isSingleDb.value || hasDefaultDatabaseOption.value)
|
||||
);
|
||||
return connection && isSchemaAware(connection.id) && (props.activeTab.database || isSingleDb.value || hasDefaultDatabaseOption.value);
|
||||
});
|
||||
|
||||
const activeSchemaOptions = computed(() => {
|
||||
|
|
@ -116,10 +100,7 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="h-9 shrink-0 border-b bg-background/80 px-3 flex items-center gap-1 text-xs text-muted-foreground relative z-10"
|
||||
:style="toolbarStyle"
|
||||
>
|
||||
<div class="h-9 shrink-0 border-b bg-background/80 px-3 flex items-center gap-1 text-xs text-muted-foreground relative z-10" :style="toolbarStyle">
|
||||
<div class="flex items-center gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
|
|
@ -127,14 +108,8 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
|
|||
:variant="activeTab.isExecuting ? 'destructive' : 'ghost'"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
:class="
|
||||
activeTab.isExecuting
|
||||
? ''
|
||||
: 'bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/20 hover:text-emerald-800 dark:text-emerald-300 dark:hover:text-emerald-200'
|
||||
"
|
||||
:disabled="
|
||||
activeTab.isCancelling || activeTab.isExplaining || (!activeTab.isExecuting && !executableSql.trim())
|
||||
"
|
||||
:class="activeTab.isExecuting ? '' : 'bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/20 hover:text-emerald-800 dark:text-emerald-300 dark:hover:text-emerald-200'"
|
||||
:disabled="activeTab.isCancelling || activeTab.isExplaining || (!activeTab.isExecuting && !executableSql.trim())"
|
||||
@click="activeTab.isExecuting ? emit('cancel') : emit('execute')"
|
||||
>
|
||||
<Loader2 v-if="activeTab.isCancelling" class="h-3.5 w-3.5 animate-spin" />
|
||||
|
|
@ -142,9 +117,7 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
|
|||
<Play v-else class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{
|
||||
activeTab.isExecuting ? t("toolbar.stopQuery") : t("toolbar.executeShortcut")
|
||||
}}</TooltipContent>
|
||||
<TooltipContent>{{ activeTab.isExecuting ? t("toolbar.stopQuery") : t("toolbar.executeShortcut") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
|
|
@ -152,11 +125,7 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
|
|||
:variant="activeTab.isExplaining ? 'destructive' : 'ghost'"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
:class="
|
||||
activeTab.isExplaining
|
||||
? ''
|
||||
: 'text-violet-600 hover:bg-violet-500/10 hover:text-violet-700 dark:text-violet-300 dark:hover:text-violet-200'
|
||||
"
|
||||
:class="activeTab.isExplaining ? '' : 'text-violet-600 hover:bg-violet-500/10 hover:text-violet-700 dark:text-violet-300 dark:hover:text-violet-200'"
|
||||
:disabled="activeTab.isExecuting || (!activeTab.isExplaining && !executableSql.trim())"
|
||||
@click="activeTab.isExplaining ? emit('cancel') : emit('explain')"
|
||||
>
|
||||
|
|
@ -164,9 +133,7 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
|
|||
<GitBranch v-else class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{
|
||||
activeTab.isExplaining ? t("toolbar.stopExplain") : t("toolbar.explainPlan")
|
||||
}}</TooltipContent>
|
||||
<TooltipContent>{{ activeTab.isExplaining ? t("toolbar.stopExplain") : t("toolbar.explainPlan") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<!-- Autotrace toggle (only for DM) -->
|
||||
<Button
|
||||
|
|
@ -174,11 +141,7 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
:class="
|
||||
props.explainMode === 'autotrace'
|
||||
? 'text-green-600 bg-green-100 dark:text-green-300 dark:bg-green-900/30'
|
||||
: 'text-muted-foreground/50'
|
||||
"
|
||||
:class="props.explainMode === 'autotrace' ? 'text-green-600 bg-green-100 dark:text-green-300 dark:bg-green-900/30' : 'text-muted-foreground/50'"
|
||||
:disabled="activeTab.isExecuting"
|
||||
@click="emit('update:explainMode', props.explainMode === 'autotrace' ? 'explain' : 'autotrace')"
|
||||
>
|
||||
|
|
@ -186,13 +149,7 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
|
|||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6 text-amber-600 hover:bg-amber-500/10 hover:text-amber-700 dark:text-amber-300 dark:hover:text-amber-200"
|
||||
:disabled="activeTab.isExecuting || activeTab.isExplaining || !activeTab.sql.trim()"
|
||||
@click="emit('formatSql')"
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6 text-amber-600 hover:bg-amber-500/10 hover:text-amber-700 dark:text-amber-300 dark:hover:text-amber-200" :disabled="activeTab.isExecuting || activeTab.isExplaining || !activeTab.sql.trim()" @click="emit('formatSql')">
|
||||
<AlignLeft class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
|
@ -200,13 +157,7 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
|
|||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6 text-blue-600 hover:bg-blue-500/10 hover:text-blue-700 dark:text-blue-300 dark:hover:text-blue-200"
|
||||
:disabled="!activeTab.sql.trim()"
|
||||
@click="emit('saveSql')"
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6 text-blue-600 hover:bg-blue-500/10 hover:text-blue-700 dark:text-blue-300 dark:hover:text-blue-200" :disabled="!activeTab.sql.trim()" @click="emit('saveSql')">
|
||||
<Save class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
|
@ -214,12 +165,7 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
|
|||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6 text-sky-600 hover:bg-sky-500/10 hover:text-sky-700 dark:text-sky-300 dark:hover:text-sky-200"
|
||||
@click="emit('openSql')"
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6 text-sky-600 hover:bg-sky-500/10 hover:text-sky-700 dark:text-sky-300 dark:hover:text-sky-200" @click="emit('openSql')">
|
||||
<FolderOpen class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
|
@ -229,11 +175,7 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
|
|||
<span class="flex-1 min-w-0" />
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<div class="flex items-center gap-1">
|
||||
<span
|
||||
v-if="activeConnection?.color"
|
||||
class="h-4 w-1 rounded-full shrink-0"
|
||||
:style="{ backgroundColor: activeConnection.color }"
|
||||
/>
|
||||
<span v-if="activeConnection?.color" class="h-4 w-1 rounded-full shrink-0" :style="{ backgroundColor: activeConnection.color }" />
|
||||
<SearchableSelect
|
||||
:model-value="activeConnectionValue"
|
||||
:options="connectionOptionIds"
|
||||
|
|
@ -264,9 +206,7 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
|
|||
<Database class="h-3.5 w-3.5 shrink-0" />
|
||||
<SearchableSelect
|
||||
:model-value="activeDatabaseValue"
|
||||
:options="
|
||||
activeDatabaseOptions.length ? activeDatabaseOptions : activeDatabaseValue ? [activeDatabaseValue] : []
|
||||
"
|
||||
:options="activeDatabaseOptions.length ? activeDatabaseOptions : activeDatabaseValue ? [activeDatabaseValue] : []"
|
||||
:placeholder="t('editor.selectDatabase')"
|
||||
:search-placeholder="t('editor.searchDatabase')"
|
||||
:empty-text="t('grid.noSearchResults')"
|
||||
|
|
@ -284,13 +224,7 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
|
|||
<TruncatedTextTooltip :text="label" class="min-w-0 flex-1" side="left" :side-offset="8" />
|
||||
</template>
|
||||
</SearchableSelect>
|
||||
<Button
|
||||
v-if="activeDatabaseValue"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 px-2 text-[11px]"
|
||||
@click="isActiveDatabaseDefault ? emit('clearDefaultDatabase') : emit('setDefaultDatabase')"
|
||||
>
|
||||
<Button v-if="activeDatabaseValue" variant="ghost" size="sm" class="h-6 px-2 text-[11px]" @click="isActiveDatabaseDefault ? emit('clearDefaultDatabase') : emit('setDefaultDatabase')">
|
||||
<Check v-if="isActiveDatabaseDefault" class="h-3 w-3" />
|
||||
{{ isActiveDatabaseDefault ? t("editor.defaultDatabase") : t("editor.setDefaultDatabase") }}
|
||||
</Button>
|
||||
|
|
@ -307,13 +241,7 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
|
|||
"
|
||||
>
|
||||
<SelectTrigger class="h-6 w-auto max-w-56 border-0 bg-transparent px-1 text-xs shadow-none focus:ring-0">
|
||||
<SelectValue
|
||||
:placeholder="
|
||||
activeConnection && isLoadingSchemas(activeConnection.id, schemaDatabaseKey)
|
||||
? t('common.loading')
|
||||
: t('editor.selectSchema')
|
||||
"
|
||||
>
|
||||
<SelectValue :placeholder="activeConnection && isLoadingSchemas(activeConnection.id, schemaDatabaseKey) ? t('common.loading') : t('editor.selectSchema')">
|
||||
{{ activeSchemaValue || t("editor.selectSchema") }}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
|
|
|||
|
|
@ -1,31 +1,9 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, reactive, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
Download,
|
||||
FileInput,
|
||||
FileText,
|
||||
FolderCog,
|
||||
FolderClosed,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
Library,
|
||||
LocateFixed,
|
||||
Pencil,
|
||||
Search,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
} from "@lucide/vue";
|
||||
import { Download, FileInput, FileText, FolderCog, FolderClosed, FolderOpen, FolderPlus, Library, LocateFixed, Pencil, Search, Trash2, Upload, X } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import CustomContextMenu, { type ContextMenuItem as CtxMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
|
|
@ -91,9 +69,7 @@ function stripSqlExtension(name: string) {
|
|||
function relativeImportName(baseDir: string, filePath: string) {
|
||||
const normalizedBase = baseDir.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const normalizedFile = filePath.replace(/\\/g, "/");
|
||||
const relative = normalizedFile.startsWith(`${normalizedBase}/`)
|
||||
? normalizedFile.slice(normalizedBase.length + 1)
|
||||
: normalizedFile.split("/").pop() || "import.sql";
|
||||
const relative = normalizedFile.startsWith(`${normalizedBase}/`) ? normalizedFile.slice(normalizedBase.length + 1) : normalizedFile.split("/").pop() || "import.sql";
|
||||
const pretty = relative.replace(/\//g, " - ");
|
||||
return ensureSqlExtension(pretty);
|
||||
}
|
||||
|
|
@ -250,11 +226,7 @@ async function importDirectoryIntoLibrary(targetFolder?: SavedSqlFolder) {
|
|||
return;
|
||||
}
|
||||
|
||||
const takenNames = new Set(
|
||||
(targetFolder ? savedSqlStore.filesInFolder(targetFolder.id) : savedSqlStore.filesWithoutFolder())
|
||||
.filter((file) => !orphanedIds.value.has(file.id))
|
||||
.map((file) => file.name),
|
||||
);
|
||||
const takenNames = new Set((targetFolder ? savedSqlStore.filesInFolder(targetFolder.id) : savedSqlStore.filesWithoutFolder()).filter((file) => !orphanedIds.value.has(file.id)).map((file) => file.name));
|
||||
|
||||
for (const path of sqlPaths) {
|
||||
const content = await readTextFile(path);
|
||||
|
|
@ -328,33 +300,26 @@ async function openSqlStorageDirectory() {
|
|||
function fileMatchesQuery(file: SavedSqlFile) {
|
||||
const q = searchQuery.value;
|
||||
if (!q) return true;
|
||||
return [file.name, file.database, file.schema, file.sql, getConnectionLabel(file.connectionId)]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(q));
|
||||
return [file.name, file.database, file.schema, file.sql, getConnectionLabel(file.connectionId)].filter(Boolean).some((value) => String(value).toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
function folderMatchesQuery(folder: SavedSqlFolder) {
|
||||
const q = searchQuery.value;
|
||||
if (!q) return true;
|
||||
if (folder.name.toLowerCase().includes(q)) return true;
|
||||
return savedSqlStore
|
||||
.filesInFolder(folder.id)
|
||||
.some((file) => !orphanedIds.value.has(file.id) && fileMatchesQuery(file));
|
||||
return savedSqlStore.filesInFolder(folder.id).some((file) => !orphanedIds.value.has(file.id) && fileMatchesQuery(file));
|
||||
}
|
||||
|
||||
function filesInFolder(folderId: string) {
|
||||
const folder = savedSqlStore.allFolders.find((item) => item.id === folderId);
|
||||
const includeAllFilesForMatchedFolder =
|
||||
!!folder && !!searchQuery.value && folder.name.toLowerCase().includes(searchQuery.value);
|
||||
const includeAllFilesForMatchedFolder = !!folder && !!searchQuery.value && folder.name.toLowerCase().includes(searchQuery.value);
|
||||
return savedSqlStore
|
||||
.filesInFolder(folderId)
|
||||
.filter((file) => !orphanedIds.value.has(file.id))
|
||||
.filter((file) => includeAllFilesForMatchedFolder || fileMatchesQuery(file));
|
||||
}
|
||||
|
||||
const visibleFolders = computed(() =>
|
||||
savedSqlStore.allFolders.filter((folder) => isConnectionVisible(folder.connectionId) && folderMatchesQuery(folder)),
|
||||
);
|
||||
const visibleFolders = computed(() => savedSqlStore.allFolders.filter((folder) => isConnectionVisible(folder.connectionId) && folderMatchesQuery(folder)));
|
||||
|
||||
const visibleFiles = computed(() =>
|
||||
savedSqlStore
|
||||
|
|
@ -756,22 +721,10 @@ function showDropInside(targetId: string) {
|
|||
<Button variant="ghost" size="icon" class="h-5 w-5" :title="t('savedSql.newFolder')" @click="openNewFolderInput">
|
||||
<FolderPlus class="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5"
|
||||
:title="t('sqlLibrary.importDirectory')"
|
||||
@click="importDirectoryIntoLibrary()"
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" :title="t('sqlLibrary.importDirectory')" @click="importDirectoryIntoLibrary()">
|
||||
<Upload class="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5"
|
||||
:title="t('sqlLibrary.exportLibrary')"
|
||||
@click="exportFolderContents()"
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" :title="t('sqlLibrary.exportLibrary')" @click="exportFolderContents()">
|
||||
<Download class="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="emit('close')">
|
||||
|
|
@ -782,20 +735,8 @@ function showDropInside(targetId: string) {
|
|||
<div class="border-b shrink-0 px-2 py-1">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
|
||||
<input
|
||||
v-model="searchText"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="w-full h-6 pl-7 pr-6 text-xs rounded border border-border bg-background focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
:placeholder="t('grid.search')"
|
||||
/>
|
||||
<button
|
||||
v-if="searchText"
|
||||
type="button"
|
||||
class="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
@click="searchText = ''"
|
||||
>
|
||||
<input v-model="searchText" autocapitalize="off" autocorrect="off" spellcheck="false" class="w-full h-6 pl-7 pr-6 text-xs rounded border border-border bg-background focus:outline-none focus:ring-1 focus:ring-ring" :placeholder="t('grid.search')" />
|
||||
<button v-if="searchText" type="button" class="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" @click="searchText = ''">
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -827,10 +768,7 @@ function showDropInside(targetId: string) {
|
|||
<div v-for="folder in visibleFolders" :key="folder.id" class="mb-0.5">
|
||||
<div
|
||||
class="relative flex items-center gap-1 rounded px-2 py-1.5 text-xs cursor-pointer transition-colors group"
|
||||
:class="[
|
||||
showDropInside(folder.id) ? 'ring-1 ring-primary/50 bg-primary/5' : 'hover:bg-accent',
|
||||
isDraggingItem(folder.id) ? 'opacity-50' : '',
|
||||
]"
|
||||
:class="[showDropInside(folder.id) ? 'ring-1 ring-primary/50 bg-primary/5' : 'hover:bg-accent', isDraggingItem(folder.id) ? 'opacity-50' : '']"
|
||||
@mousedown="handleDragMouseDown($event, folder.id, 'folder')"
|
||||
@mousemove="updateDropTarget($event, folder.id, 'folder')"
|
||||
@mouseleave="clearDropTarget(folder.id)"
|
||||
|
|
@ -841,14 +779,8 @@ function showDropInside(targetId: string) {
|
|||
"
|
||||
>
|
||||
<div v-if="showDropBefore(folder.id)" class="absolute left-2 right-2 top-0 border-t-2 border-primary" />
|
||||
<div
|
||||
v-if="showDropAfter(folder.id)"
|
||||
class="absolute left-2 right-2 bottom-0 border-b-2 border-primary"
|
||||
/>
|
||||
<component
|
||||
:is="isFolderExpanded(folder.id) ? FolderOpen : FolderClosed"
|
||||
class="h-4 w-4 text-amber-500 shrink-0"
|
||||
/>
|
||||
<div v-if="showDropAfter(folder.id)" class="absolute left-2 right-2 bottom-0 border-b-2 border-primary" />
|
||||
<component :is="isFolderExpanded(folder.id) ? FolderOpen : FolderClosed" class="h-4 w-4 text-amber-500 shrink-0" />
|
||||
<template v-if="renamingTarget?.type === 'folder' && renamingTarget.id === folder.id">
|
||||
<input
|
||||
:ref="setRenameInputRef"
|
||||
|
|
@ -883,10 +815,7 @@ function showDropInside(targetId: string) {
|
|||
"
|
||||
>
|
||||
<div v-if="showDropBefore(file.id)" class="absolute left-2 right-2 top-0 border-t-2 border-primary" />
|
||||
<div
|
||||
v-if="showDropAfter(file.id)"
|
||||
class="absolute left-2 right-2 bottom-0 border-b-2 border-primary"
|
||||
/>
|
||||
<div v-if="showDropAfter(file.id)" class="absolute left-2 right-2 bottom-0 border-b-2 border-primary" />
|
||||
<FileText class="h-3.5 w-3.5 text-blue-400 shrink-0" />
|
||||
<template v-if="renamingTarget?.type === 'file' && renamingTarget.id === file.id">
|
||||
<input
|
||||
|
|
@ -901,9 +830,7 @@ function showDropInside(targetId: string) {
|
|||
/>
|
||||
</template>
|
||||
<span v-else class="dbx-sql-library-drag-label min-w-0 flex-1 truncate">{{ file.name }}</span>
|
||||
<span class="shrink-0 text-xs text-muted-foreground">
|
||||
[{{ getConnectionLabel(file.connectionId) }}]
|
||||
</span>
|
||||
<span class="shrink-0 text-xs text-muted-foreground"> [{{ getConnectionLabel(file.connectionId) }}] </span>
|
||||
</div>
|
||||
|
||||
<div v-if="filesInFolder(folder.id).length === 0" class="px-2 py-1 text-xs text-muted-foreground">
|
||||
|
|
@ -951,16 +878,11 @@ function showDropInside(targetId: string) {
|
|||
/>
|
||||
</template>
|
||||
<span v-else class="dbx-sql-library-drag-label min-w-0 flex-1 truncate">{{ file.name }}</span>
|
||||
<span class="shrink-0 text-xs text-muted-foreground">
|
||||
[{{ getConnectionLabel(file.connectionId) }}]
|
||||
</span>
|
||||
<span class="shrink-0 text-xs text-muted-foreground"> [{{ getConnectionLabel(file.connectionId) }}] </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!hasAnyVisibleItem && !showNewFolderInput"
|
||||
class="flex h-full flex-col items-center justify-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<div v-if="!hasAnyVisibleItem && !showNewFolderInput" class="flex h-full flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<Library class="h-8 w-8 opacity-30" />
|
||||
<p class="text-xs">{{ t("sqlLibrary.empty") }}</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -99,17 +99,12 @@ watch(
|
|||
<code class="bg-muted px-1 py-0.5 rounded text-[11px]">docker compose pull && docker compose up -d</code>
|
||||
{{ t("updates.toUpdate") }}
|
||||
</p>
|
||||
<p
|
||||
v-if="isDesktop && updateInfo?.update_available && updateInfo.portable_mode"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
<p v-if="isDesktop && updateInfo?.update_available && updateInfo.portable_mode" class="text-xs text-muted-foreground">
|
||||
{{ t("updates.portableManualUpdate") }}
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button v-if="!isDownloadingUpdate && !updateReady" variant="outline" @click="open = false">{{
|
||||
t("dangerDialog.cancel")
|
||||
}}</Button>
|
||||
<Button v-if="!isDownloadingUpdate && !updateReady" variant="outline" @click="open = false">{{ t("dangerDialog.cancel") }}</Button>
|
||||
<template v-if="updateInfo?.update_available">
|
||||
<Button variant="outline" @click="emit('open-latest-release')">{{ t("updates.openRelease") }}</Button>
|
||||
<template v-if="canDownloadAndInstallUpdate(updateInfo, isDesktop)">
|
||||
|
|
@ -124,9 +119,7 @@ watch(
|
|||
<Button v-else @click="emit('download-and-install')">{{ t("updates.downloadAndInstall") }}</Button>
|
||||
</template>
|
||||
</template>
|
||||
<Button v-else-if="updateCheckMessage" @click="emit('open-latest-release')">{{
|
||||
t("updates.openRelease")
|
||||
}}</Button>
|
||||
<Button v-else-if="updateCheckMessage" @click="emit('open-latest-release')">{{ t("updates.openRelease") }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -2,12 +2,7 @@
|
|||
import { useI18n } from "vue-i18n";
|
||||
import { FilePlus2, Plus, History, Download, Database, Search, ShieldCheck, Sparkles } from "@lucide/vue";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import {
|
||||
connectionDriverLabel,
|
||||
connectionIconType,
|
||||
connectionRedactedNameLabel,
|
||||
connectionRedactedOptionSubtitle,
|
||||
} from "@/lib/connectionPresentation";
|
||||
import { connectionDriverLabel, connectionIconType, connectionRedactedNameLabel, connectionRedactedOptionSubtitle } from "@/lib/connectionPresentation";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
|
||||
defineProps<{
|
||||
|
|
@ -35,21 +30,15 @@ const { t } = useI18n();
|
|||
<div class="mx-auto flex min-h-full w-full min-w-0 max-w-5xl flex-col justify-center gap-6 px-8 py-10">
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div class="rounded-lg border bg-muted/20 px-4 py-3">
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Database class="h-3.5 w-3.5" /> {{ t("welcome.connections") }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground"><Database class="h-3.5 w-3.5" /> {{ t("welcome.connections") }}</div>
|
||||
<div class="mt-2 text-2xl font-semibold">{{ connectionStats.total }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border bg-muted/20 px-4 py-3">
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<ShieldCheck class="h-3.5 w-3.5" /> {{ t("welcome.connected") }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground"><ShieldCheck class="h-3.5 w-3.5" /> {{ t("welcome.connected") }}</div>
|
||||
<div class="mt-2 text-2xl font-semibold">{{ connectionStats.connected }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border bg-muted/20 px-4 py-3">
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Sparkles class="h-3.5 w-3.5" /> {{ t("welcome.databaseTypes") }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground"><Sparkles class="h-3.5 w-3.5" /> {{ t("welcome.databaseTypes") }}</div>
|
||||
<div class="mt-2 text-2xl font-semibold">{{ connectionStats.types }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -60,12 +49,7 @@ const { t } = useI18n();
|
|||
<div class="text-sm font-medium">{{ t("welcome.quickConnections") }}</div>
|
||||
</div>
|
||||
<div class="divide-y">
|
||||
<button
|
||||
v-for="connection in recentConnections"
|
||||
:key="connection.id"
|
||||
class="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-muted/40"
|
||||
@click="emit('open-connection-query', connection.id)"
|
||||
>
|
||||
<button v-for="connection in recentConnections" :key="connection.id" class="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-muted/40" @click="emit('open-connection-query', connection.id)">
|
||||
<DatabaseIcon :db-type="connectionIconType(connection)" class="h-4 w-4" />
|
||||
<span class="h-5 w-1 rounded-full shrink-0" :style="{ backgroundColor: connection.color || '#9ca3af' }" />
|
||||
<div class="min-w-0 flex-1">
|
||||
|
|
@ -87,31 +71,10 @@ const { t } = useI18n();
|
|||
<div class="text-sm font-medium">{{ t("welcome.shortcuts") }}</div>
|
||||
</div>
|
||||
<div class="grid gap-1 p-2">
|
||||
<button
|
||||
class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50"
|
||||
@click="emit('new-connection')"
|
||||
>
|
||||
<Plus class="h-4 w-4" /> {{ t("toolbar.newConnection") }}
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50"
|
||||
:disabled="!hasConnections"
|
||||
@click="emit('new-query')"
|
||||
>
|
||||
<FilePlus2 class="h-4 w-4" /> {{ t("toolbar.newQuery") }}
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50"
|
||||
@click="emit('show-history')"
|
||||
>
|
||||
<History class="h-4 w-4" /> {{ t("history.title") }}
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50"
|
||||
@click="emit('import-config')"
|
||||
>
|
||||
<Download class="h-4 w-4" /> {{ t("sidebar.import") }}
|
||||
</button>
|
||||
<button class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50" @click="emit('new-connection')"><Plus class="h-4 w-4" /> {{ t("toolbar.newConnection") }}</button>
|
||||
<button class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50" :disabled="!hasConnections" @click="emit('new-query')"><FilePlus2 class="h-4 w-4" /> {{ t("toolbar.newQuery") }}</button>
|
||||
<button class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50" @click="emit('show-history')"><History class="h-4 w-4" /> {{ t("history.title") }}</button>
|
||||
<button class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50" @click="emit('import-config')"><Download class="h-4 w-4" /> {{ t("sidebar.import") }}</button>
|
||||
<div class="mt-2 rounded-md bg-muted/30 px-3 py-2 text-xs leading-5 text-muted-foreground">
|
||||
<Search class="mr-1 inline h-3.5 w-3.5" />
|
||||
{{ t("welcome.tip") }}
|
||||
|
|
@ -130,12 +93,8 @@ const { t } = useI18n();
|
|||
{{ t("welcome.mcpDescription") }}
|
||||
</p>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||
<code class="max-w-full break-all rounded bg-muted px-2 py-0.5 text-[11px] select-all"
|
||||
>npx @dbx-app/mcp-server</code
|
||||
>
|
||||
<a href="#" class="text-xs text-primary hover:underline" @click.prevent="emit('open-mcp-guide')">{{
|
||||
t("welcome.mcpLearnMore")
|
||||
}}</a>
|
||||
<code class="max-w-full break-all rounded bg-muted px-2 py-0.5 text-[11px] select-all">npx @dbx-app/mcp-server</code>
|
||||
<a href="#" class="text-xs text-primary hover:underline" @click.prevent="emit('open-mcp-guide')">{{ t("welcome.mcpLearnMore") }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -14,23 +14,14 @@ const emit = defineEmits<{
|
|||
|
||||
<template>
|
||||
<div class="flex items-stretch -mr-2 ml-1">
|
||||
<button
|
||||
class="inline-flex items-center justify-center w-11.5 h-10 hover:bg-foreground/10 transition-colors"
|
||||
@click="emit('minimize')"
|
||||
>
|
||||
<button class="inline-flex items-center justify-center w-11.5 h-10 hover:bg-foreground/10 transition-colors" @click="emit('minimize')">
|
||||
<Minus class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center justify-center w-11.5 h-10 hover:bg-foreground/10 transition-colors"
|
||||
@click="emit('toggle-maximize')"
|
||||
>
|
||||
<button class="inline-flex items-center justify-center w-11.5 h-10 hover:bg-foreground/10 transition-colors" @click="emit('toggle-maximize')">
|
||||
<Copy v-if="isMaximized" class="h-3.5 w-3.5" />
|
||||
<Square v-else class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center justify-center w-11.5 h-10 hover:bg-red-500 hover:text-white transition-colors"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<button class="inline-flex items-center justify-center w-11.5 h-10 hover:bg-red-500 hover:text-white transition-colors" @click="emit('close')">
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,21 +2,7 @@
|
|||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
Check,
|
||||
Columns3,
|
||||
Copy,
|
||||
Eye,
|
||||
Filter,
|
||||
History,
|
||||
Link,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Search,
|
||||
SearchX,
|
||||
X,
|
||||
} from "@lucide/vue";
|
||||
import { ArrowUpRight, Check, Columns3, Copy, Eye, Filter, History, Link, Loader2, RefreshCw, Search, SearchX, X } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog, DialogFooter, DialogHeader, DialogScrollContent, DialogTitle } from "@/components/ui/dialog";
|
||||
|
|
@ -24,15 +10,7 @@ import { Input } from "@/components/ui/input";
|
|||
import { useToast } from "@/composables/useToast";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import * as api from "@/lib/api";
|
||||
import {
|
||||
analyzeFieldLineage,
|
||||
summarizeLineageCounts,
|
||||
type FieldLineageConfidence,
|
||||
type FieldLineageItem,
|
||||
type FieldLineageResult,
|
||||
type FieldLineageTable,
|
||||
type FieldLineageView,
|
||||
} from "@/lib/fieldLineage";
|
||||
import { analyzeFieldLineage, summarizeLineageCounts, type FieldLineageConfidence, type FieldLineageItem, type FieldLineageResult, type FieldLineageTable, type FieldLineageView } from "@/lib/fieldLineage";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -88,14 +66,12 @@ const targetLabel = computed(() => {
|
|||
|
||||
const counts = computed(() => summarizeLineageCounts(result.value?.items ?? []));
|
||||
|
||||
const confidenceOptions = computed<Array<{ value: "all" | FieldLineageConfidence; label: string; count: number }>>(
|
||||
() => [
|
||||
{ value: "all", label: t("lineage.all"), count: result.value?.items.length ?? 0 },
|
||||
{ value: "certain", label: t("lineage.certain"), count: counts.value.certain },
|
||||
{ value: "likely", label: t("lineage.likely"), count: counts.value.likely },
|
||||
{ value: "possible", label: t("lineage.possible"), count: counts.value.possible },
|
||||
],
|
||||
);
|
||||
const confidenceOptions = computed<Array<{ value: "all" | FieldLineageConfidence; label: string; count: number }>>(() => [
|
||||
{ value: "all", label: t("lineage.all"), count: result.value?.items.length ?? 0 },
|
||||
{ value: "certain", label: t("lineage.certain"), count: counts.value.certain },
|
||||
{ value: "likely", label: t("lineage.likely"), count: counts.value.likely },
|
||||
{ value: "possible", label: t("lineage.possible"), count: counts.value.possible },
|
||||
]);
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
const query = searchText.value.trim().toLowerCase();
|
||||
|
|
@ -103,15 +79,7 @@ const filteredItems = computed(() => {
|
|||
.filter((item) => confidenceFilter.value === "all" || item.confidence === confidenceFilter.value)
|
||||
.filter((item) => {
|
||||
if (!query) return true;
|
||||
return [
|
||||
item.title,
|
||||
item.schema,
|
||||
item.table,
|
||||
item.column,
|
||||
item.sqlSnippet,
|
||||
itemKindLabel(item),
|
||||
itemDescription(item),
|
||||
].some((value) =>
|
||||
return [item.title, item.schema, item.table, item.column, item.sqlSnippet, itemKindLabel(item), itemDescription(item)].some((value) =>
|
||||
String(value ?? "")
|
||||
.toLowerCase()
|
||||
.includes(query),
|
||||
|
|
@ -157,13 +125,8 @@ async function loadLineage() {
|
|||
if (isStale(currentRun)) return;
|
||||
|
||||
const schema = props.prefillSchema || props.prefillDatabase;
|
||||
const tableInfos = prioritizeTargetTable(
|
||||
await api.listTables(props.prefillConnectionId, props.prefillDatabase, schema),
|
||||
props.prefillTable,
|
||||
).slice(0, MAX_TABLES);
|
||||
const viewInfos = tableInfos
|
||||
.filter((table) => table.table_type.toUpperCase().includes("VIEW"))
|
||||
.slice(0, MAX_VIEW_DDLS);
|
||||
const tableInfos = prioritizeTargetTable(await api.listTables(props.prefillConnectionId, props.prefillDatabase, schema), props.prefillTable).slice(0, MAX_TABLES);
|
||||
const viewInfos = tableInfos.filter((table) => table.table_type.toUpperCase().includes("VIEW")).slice(0, MAX_VIEW_DDLS);
|
||||
progressTotal.value = tableInfos.length + viewInfos.length + 1;
|
||||
|
||||
const tables: FieldLineageTable[] = [];
|
||||
|
|
@ -174,12 +137,7 @@ async function loadLineage() {
|
|||
batch.map(async (table) => {
|
||||
try {
|
||||
const columns = await api.getColumns(props.prefillConnectionId, props.prefillDatabase, schema, table.name);
|
||||
const foreignKeys = await api.listForeignKeys(
|
||||
props.prefillConnectionId,
|
||||
props.prefillDatabase,
|
||||
schema,
|
||||
table.name,
|
||||
);
|
||||
const foreignKeys = await api.listForeignKeys(props.prefillConnectionId, props.prefillDatabase, schema, table.name);
|
||||
return {
|
||||
schema,
|
||||
name: table.name,
|
||||
|
|
@ -208,9 +166,7 @@ async function loadLineage() {
|
|||
}
|
||||
}
|
||||
|
||||
const histories = (await api.loadHistory(200, 0))
|
||||
.filter((entry) => !entry.database || entry.database === props.prefillDatabase)
|
||||
.map((entry) => ({ id: entry.id, sql: entry.sql, executed_at: entry.executed_at }));
|
||||
const histories = (await api.loadHistory(200, 0)).filter((entry) => !entry.database || entry.database === props.prefillDatabase).map((entry) => ({ id: entry.id, sql: entry.sql, executed_at: entry.executed_at }));
|
||||
progressDone.value++;
|
||||
if (isStale(currentRun)) return;
|
||||
|
||||
|
|
@ -258,8 +214,7 @@ function confidenceTone(confidence: FieldLineageItem["confidence"]) {
|
|||
|
||||
function itemRank(item: FieldLineageItem) {
|
||||
const confidenceRank = item.confidence === "certain" ? 0 : item.confidence === "likely" ? 10 : 20;
|
||||
const kindRank =
|
||||
item.kind === "foreignKey" ? 0 : item.kind === "viewReference" ? 1 : item.kind === "historyReference" ? 2 : 3;
|
||||
const kindRank = item.kind === "foreignKey" ? 0 : item.kind === "viewReference" ? 1 : item.kind === "historyReference" ? 2 : 3;
|
||||
return confidenceRank + kindRank;
|
||||
}
|
||||
|
||||
|
|
@ -287,9 +242,7 @@ function itemDescription(item: FieldLineageItem) {
|
|||
return item.confidence === "likely" ? t("lineage.description.viewLikely") : t("lineage.description.viewPossible");
|
||||
}
|
||||
if (item.kind === "historyReference") {
|
||||
return item.confidence === "likely"
|
||||
? t("lineage.description.historyLikely")
|
||||
: t("lineage.description.historyPossible");
|
||||
return item.confidence === "likely" ? t("lineage.description.historyLikely") : t("lineage.description.historyPossible");
|
||||
}
|
||||
return t("lineage.description.sameName");
|
||||
}
|
||||
|
|
@ -322,9 +275,7 @@ function openItemTarget(item: FieldLineageItem) {
|
|||
|
||||
<template>
|
||||
<Dialog v-model:open="dialogOpen">
|
||||
<DialogScrollContent
|
||||
class="h-[78vh] min-h-[560px] max-h-[780px] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden gap-0 p-0 sm:max-w-[980px]"
|
||||
>
|
||||
<DialogScrollContent class="h-[78vh] min-h-[560px] max-h-[780px] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden gap-0 p-0 sm:max-w-[980px]">
|
||||
<DialogHeader class="border-b px-6 py-4 pr-12">
|
||||
<DialogTitle class="flex items-center gap-2 text-lg">
|
||||
<Link class="h-5 w-5" />
|
||||
|
|
@ -357,16 +308,7 @@ function openItemTarget(item: FieldLineageItem) {
|
|||
</div>
|
||||
<div class="flex items-center gap-2 overflow-x-auto">
|
||||
<Filter class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<Button
|
||||
v-for="option in confidenceOptions"
|
||||
:key="option.value"
|
||||
size="sm"
|
||||
:variant="confidenceFilter === option.value ? 'default' : 'outline'"
|
||||
class="h-8 shrink-0 px-3"
|
||||
@click="confidenceFilter = option.value"
|
||||
>
|
||||
{{ option.label }} {{ option.count }}
|
||||
</Button>
|
||||
<Button v-for="option in confidenceOptions" :key="option.value" size="sm" :variant="confidenceFilter === option.value ? 'default' : 'outline'" class="h-8 shrink-0 px-3" @click="confidenceFilter = option.value"> {{ option.label }} {{ option.count }} </Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -379,17 +321,11 @@ function openItemTarget(item: FieldLineageItem) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive"
|
||||
>
|
||||
<div v-else-if="error" class="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="result && result.items.length === 0"
|
||||
class="flex flex-col items-center justify-center gap-2 rounded-md border py-12 text-sm text-muted-foreground"
|
||||
>
|
||||
<div v-else-if="result && result.items.length === 0" class="flex flex-col items-center justify-center gap-2 rounded-md border py-12 text-sm text-muted-foreground">
|
||||
<SearchX class="h-8 w-8" />
|
||||
{{ t("lineage.empty") }}
|
||||
</div>
|
||||
|
|
@ -398,33 +334,20 @@ function openItemTarget(item: FieldLineageItem) {
|
|||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span>{{ t("lineage.showing", { shown: filteredItems.length, total: result.items.length }) }}</span>
|
||||
<span v-if="filteredItems.length">
|
||||
{{ t("lineage.certain") }} {{ filteredCounts.certain }} · {{ t("lineage.likely") }}
|
||||
{{ filteredCounts.likely }} · {{ t("lineage.possible") }}
|
||||
{{ t("lineage.certain") }} {{ filteredCounts.certain }} · {{ t("lineage.likely") }} {{ filteredCounts.likely }} · {{ t("lineage.possible") }}
|
||||
{{ filteredCounts.possible }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="filteredItems.length === 0"
|
||||
class="flex flex-col items-center justify-center gap-2 rounded-md border py-12 text-sm text-muted-foreground"
|
||||
>
|
||||
<div v-if="filteredItems.length === 0" class="flex flex-col items-center justify-center gap-2 rounded-md border py-12 text-sm text-muted-foreground">
|
||||
<SearchX class="h-8 w-8" />
|
||||
{{ t("lineage.noFiltered") }}
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="item in filteredItems"
|
||||
:key="item.id"
|
||||
class="rounded-md border bg-background transition-colors hover:bg-muted/25"
|
||||
>
|
||||
<div v-for="item in filteredItems" :key="item.id" class="rounded-md border bg-background transition-colors hover:bg-muted/25">
|
||||
<div class="flex items-start gap-3 p-3">
|
||||
<div
|
||||
:class="[
|
||||
'mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md border',
|
||||
confidenceTone(item.confidence),
|
||||
]"
|
||||
>
|
||||
<div :class="['mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md border', confidenceTone(item.confidence)]">
|
||||
<component :is="itemIcon(item)" class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
|
|
@ -437,33 +360,19 @@ function openItemTarget(item: FieldLineageItem) {
|
|||
@click="openItemTarget(item)"
|
||||
>
|
||||
<span class="truncate">{{ itemPrimaryLabel(item) }}</span>
|
||||
<ArrowUpRight
|
||||
class="h-3.5 w-3.5 shrink-0 opacity-70 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
<ArrowUpRight class="h-3.5 w-3.5 shrink-0 opacity-70 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
<span v-else class="max-w-[560px] truncate font-medium">{{ itemPrimaryLabel(item) }}</span>
|
||||
<Badge v-if="item.schema" variant="outline" class="text-[10px]">{{ item.schema }}</Badge>
|
||||
<Badge variant="outline" class="text-[10px]">{{ itemKindLabel(item) }}</Badge>
|
||||
<Badge :variant="confidenceVariant(item.confidence)" class="text-[10px]">{{
|
||||
t(`lineage.${item.confidence}`)
|
||||
}}</Badge>
|
||||
<Badge :variant="confidenceVariant(item.confidence)" class="text-[10px]">{{ t(`lineage.${item.confidence}`) }}</Badge>
|
||||
</div>
|
||||
<p class="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
{{ itemDescription(item) }}
|
||||
</p>
|
||||
<pre
|
||||
v-if="item.sqlSnippet"
|
||||
class="mt-2 max-h-20 overflow-auto rounded-md bg-muted/40 p-2 text-xs whitespace-pre-wrap"
|
||||
v-html="highlight(item.sqlSnippet)"
|
||||
/>
|
||||
<pre v-if="item.sqlSnippet" class="mt-2 max-h-20 overflow-auto rounded-md bg-muted/40 p-2 text-xs whitespace-pre-wrap" v-html="highlight(item.sqlSnippet)" />
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 w-8 shrink-0 p-0"
|
||||
:title="t('lineage.copy')"
|
||||
@click="copyItem(item)"
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="h-8 w-8 shrink-0 p-0" :title="t('lineage.copy')" @click="copyItem(item)">
|
||||
<Check v-if="copiedId === item.id" class="h-4 w-4 text-emerald-600" />
|
||||
<Copy v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -116,29 +116,13 @@ function confirmRemoveChild() {
|
|||
</template>
|
||||
<template v-else>
|
||||
<span class="json-edit-quote">"</span>
|
||||
<input
|
||||
v-model="node.keyName"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="json-edit-key-input"
|
||||
:disabled="node.readonlyKey"
|
||||
:placeholder="t('mongo.fieldPlaceholder')"
|
||||
/>
|
||||
<input v-model="node.keyName" autocapitalize="off" autocorrect="off" spellcheck="false" class="json-edit-key-input" :disabled="node.readonlyKey" :placeholder="t('mongo.fieldPlaceholder')" />
|
||||
<span class="json-edit-quote">"</span>
|
||||
</template>
|
||||
<span class="json-edit-colon">:</span>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-if="node.kind === 'value'"
|
||||
v-model="node.valueText"
|
||||
class="json-edit-value"
|
||||
:class="[fieldValueTone(node.valueText), { 'is-readonly': node.readonlyValue }]"
|
||||
:disabled="node.readonlyValue"
|
||||
:rows="fieldRows(node.valueText)"
|
||||
wrap="soft"
|
||||
/>
|
||||
<textarea v-if="node.kind === 'value'" v-model="node.valueText" class="json-edit-value" :class="[fieldValueTone(node.valueText), { 'is-readonly': node.readonlyValue }]" :disabled="node.readonlyValue" :rows="fieldRows(node.valueText)" wrap="soft" />
|
||||
<div v-else class="json-edit-container-open">
|
||||
<span>{{ node.kind === "array" ? "[" : "{" }}</span>
|
||||
<span class="json-edit-count">{{ node.children.length }}</span>
|
||||
|
|
@ -146,43 +130,21 @@ function confirmRemoveChild() {
|
|||
|
||||
<span class="json-edit-comma">{{ node.kind === "value" ? "," : "" }}</span>
|
||||
|
||||
<Button
|
||||
v-if="removable"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="json-edit-remove"
|
||||
:title="t('mongo.deleteField')"
|
||||
@click="emit('remove')"
|
||||
>
|
||||
<Button v-if="removable" variant="ghost" size="icon" class="json-edit-remove" :title="t('mongo.deleteField')" @click="emit('remove')">
|
||||
<Trash2 class="w-3 h-3" />
|
||||
</Button>
|
||||
<span v-else-if="node.readonlyValue" class="json-edit-lock">{{ t("mongo.readonlyId") }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="isContainer" class="json-edit-children" :style="{ '--mongo-key-width': childKeyWidth }">
|
||||
<JsonEditNode
|
||||
v-for="(child, idx) in node.children"
|
||||
:key="child.key"
|
||||
:node="child"
|
||||
:parent-kind="node.kind"
|
||||
:removable="!child.readonlyValue || node.kind === 'array'"
|
||||
@remove="requestRemoveChild(idx)"
|
||||
/>
|
||||
<JsonEditNode v-for="(child, idx) in node.children" :key="child.key" :node="child" :parent-kind="node.kind" :removable="!child.readonlyValue || node.kind === 'array'" @remove="requestRemoveChild(idx)" />
|
||||
|
||||
<Button variant="ghost" size="sm" class="json-edit-add" @click="addChild">
|
||||
<Plus class="w-3 h-3 mr-1" /> {{ t("mongo.addField") }}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" class="json-edit-add" @click="addChild"> <Plus class="w-3 h-3 mr-1" /> {{ t("mongo.addField") }} </Button>
|
||||
|
||||
<div class="json-edit-close">{{ node.kind === "array" ? "]" : "}" }}<span class="json-edit-comma">,</span></div>
|
||||
</div>
|
||||
|
||||
<DangerConfirmDialog
|
||||
v-model:open="showChildDeleteConfirm"
|
||||
:message="t('dangerDialog.deleteMessage')"
|
||||
:details="childDeleteDetails"
|
||||
:confirm-label="t('mongo.deleteField')"
|
||||
@confirm="confirmRemoveChild"
|
||||
/>
|
||||
<DangerConfirmDialog v-model:open="showChildDeleteConfirm" :message="t('dangerDialog.deleteMessage')" :details="childDeleteDetails" :confirm-label="t('mongo.deleteField')" @confirm="confirmRemoveChild" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -2,24 +2,7 @@
|
|||
import { computed, ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import { uuid } from "@/lib/utils";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
RefreshCw,
|
||||
RefreshCcw,
|
||||
Loader2,
|
||||
Trash2,
|
||||
Plus,
|
||||
Save,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Table2,
|
||||
Braces,
|
||||
X,
|
||||
Columns3,
|
||||
Check,
|
||||
Search,
|
||||
Wrench,
|
||||
Filter,
|
||||
} from "@lucide/vue";
|
||||
import { RefreshCw, RefreshCcw, Loader2, Trash2, Plus, Save, ChevronLeft, ChevronRight, Table2, Braces, X, Columns3, Check, Search, Wrench, Filter } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -70,9 +53,7 @@ const filterInput = ref("");
|
|||
const sortInput = ref("");
|
||||
const dataGridRef = ref<InstanceType<typeof DataGrid>>();
|
||||
const columnVisibilitySearch = ref("");
|
||||
const columnVisibilityOptions = computed(
|
||||
() => dataGridRef.value?.filteredColumnVisibilityOptions(columnVisibilitySearch.value) ?? [],
|
||||
);
|
||||
const columnVisibilityOptions = computed(() => dataGridRef.value?.filteredColumnVisibilityOptions(columnVisibilitySearch.value) ?? []);
|
||||
const tableSearchSplitContainerRef = ref<HTMLDivElement>();
|
||||
const tableFindPaneWidth = ref<number | null>(null);
|
||||
const isResizingTableSearchSplit = ref(false);
|
||||
|
|
@ -91,15 +72,7 @@ type LocalFilterSummary = {
|
|||
values: string[];
|
||||
hiddenValueCount: number;
|
||||
};
|
||||
type MongoFilterMode =
|
||||
| "equals"
|
||||
| "not-equals"
|
||||
| "like"
|
||||
| "not-like"
|
||||
| "greater-than"
|
||||
| "less-than"
|
||||
| "is-null"
|
||||
| "is-not-null";
|
||||
type MongoFilterMode = "equals" | "not-equals" | "like" | "not-like" | "greater-than" | "less-than" | "is-null" | "is-not-null";
|
||||
type MongoFilterRule = {
|
||||
id: string;
|
||||
fieldName: string;
|
||||
|
|
@ -260,10 +233,7 @@ function mongoConditionForRule(rule: MongoFilterRule): Record<string, unknown> |
|
|||
}
|
||||
}
|
||||
|
||||
function combineMongoConditions(
|
||||
conditions: Record<string, unknown>[],
|
||||
rules: MongoFilterRule[],
|
||||
): Record<string, unknown> | null {
|
||||
function combineMongoConditions(conditions: Record<string, unknown>[], rules: MongoFilterRule[]): Record<string, unknown> | null {
|
||||
if (conditions.length === 0) return null;
|
||||
let result = conditions[0];
|
||||
for (let i = 1; i < conditions.length; i++) {
|
||||
|
|
@ -302,9 +272,7 @@ const mongoQueryPreview = computed(() => {
|
|||
});
|
||||
|
||||
async function applyMongoStructuredFilters() {
|
||||
const items = mongoFilterRules.value
|
||||
.map((rule) => ({ rule, condition: mongoConditionForRule(rule) }))
|
||||
.filter((item): item is { rule: MongoFilterRule; condition: Record<string, unknown> } => !!item.condition);
|
||||
const items = mongoFilterRules.value.map((rule) => ({ rule, condition: mongoConditionForRule(rule) })).filter((item): item is { rule: MongoFilterRule; condition: Record<string, unknown> } => !!item.condition);
|
||||
const structured = combineMongoConditions(
|
||||
items.map((item) => item.condition),
|
||||
items.map((item) => item.rule),
|
||||
|
|
@ -321,12 +289,7 @@ function clearMongoFilters(clearLocalFilter?: (columnIndex?: number) => void) {
|
|||
applyFilter();
|
||||
}
|
||||
|
||||
async function gridSave(changes: {
|
||||
dirtyRows: Map<number, Map<number, string | number | boolean | null>>;
|
||||
deletedRows: Set<number>;
|
||||
columns: string[];
|
||||
rows: (string | number | boolean | null)[][];
|
||||
}) {
|
||||
async function gridSave(changes: { dirtyRows: Map<number, Map<number, string | number | boolean | null>>; deletedRows: Set<number>; columns: string[]; rows: (string | number | boolean | null)[][] }) {
|
||||
const cols = changes.columns;
|
||||
const idColIdx = cols.indexOf("_id");
|
||||
if (idColIdx < 0) throw new Error("No _id column");
|
||||
|
|
@ -353,13 +316,7 @@ async function gridSave(changes: {
|
|||
updated[col] = newVal;
|
||||
}
|
||||
}
|
||||
await api.mongoUpdateDocument(
|
||||
props.connectionId,
|
||||
props.database,
|
||||
props.collection,
|
||||
String(id),
|
||||
JSON.stringify(updated),
|
||||
);
|
||||
await api.mongoUpdateDocument(props.connectionId, props.database, props.collection, String(id), JSON.stringify(updated));
|
||||
}
|
||||
|
||||
for (const rowIdx of changes.deletedRows) {
|
||||
|
|
@ -378,15 +335,7 @@ async function load() {
|
|||
try {
|
||||
const filter = currentMongoFilter();
|
||||
const sort = sortInput.value.trim() || undefined;
|
||||
const result = await api.mongoFindDocuments(
|
||||
props.connectionId,
|
||||
props.database,
|
||||
props.collection,
|
||||
page.value * pageSize.value,
|
||||
pageSize.value,
|
||||
filter,
|
||||
sort,
|
||||
);
|
||||
const result = await api.mongoFindDocuments(props.connectionId, props.database, props.collection, page.value * pageSize.value, pageSize.value, filter, sort);
|
||||
const nextDocuments = result.documents.map(asRecord);
|
||||
documents.value = nextDocuments;
|
||||
if (nextDocuments.length > 0) {
|
||||
|
|
@ -455,9 +404,7 @@ function startNew() {
|
|||
function startEdit() {
|
||||
const doc = selectedDoc.value;
|
||||
if (!doc) return;
|
||||
editFields.value = Object.entries(doc).map(([name, value]) =>
|
||||
createEditNode(name, value, name === "_id", name === "_id"),
|
||||
);
|
||||
editFields.value = Object.entries(doc).map(([name, value]) => createEditNode(name, value, name === "_id", name === "_id"));
|
||||
isEditing.value = true;
|
||||
isNew.value = false;
|
||||
}
|
||||
|
|
@ -497,9 +444,7 @@ function createEditNode(keyName: string, value: unknown, readonlyKey: boolean, r
|
|||
valueText: "",
|
||||
readonlyKey,
|
||||
readonlyValue,
|
||||
children: Object.entries(value as JsonRecord).map(([childName, child]) =>
|
||||
createEditNode(childName, child, readonlyValue, readonlyValue),
|
||||
),
|
||||
children: Object.entries(value as JsonRecord).map(([childName, child]) => createEditNode(childName, child, readonlyValue, readonlyValue)),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -589,13 +534,7 @@ async function saveDoc() {
|
|||
error.value = "No _id field";
|
||||
return;
|
||||
}
|
||||
await api.mongoUpdateDocument(
|
||||
props.connectionId,
|
||||
props.database,
|
||||
props.collection,
|
||||
String(id),
|
||||
JSON.stringify(doc),
|
||||
);
|
||||
await api.mongoUpdateDocument(props.connectionId, props.database, props.collection, String(id), JSON.stringify(doc));
|
||||
}
|
||||
isEditing.value = false;
|
||||
isNew.value = false;
|
||||
|
|
@ -666,16 +605,13 @@ function docPreview(doc: JsonRecord): string {
|
|||
function highlightedJson(json: string): string {
|
||||
const escaped = json.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
return escaped.replace(
|
||||
/("(?:\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g,
|
||||
(match) => {
|
||||
let cls = "json-number";
|
||||
if (match.startsWith('"')) cls = match.endsWith(":") ? "json-key" : "json-string";
|
||||
else if (match === "true" || match === "false") cls = "json-boolean";
|
||||
else if (match === "null") cls = "json-null";
|
||||
return `<span class="${cls}">${match}</span>`;
|
||||
},
|
||||
);
|
||||
return escaped.replace(/("(?:\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g, (match) => {
|
||||
let cls = "json-number";
|
||||
if (match.startsWith('"')) cls = match.endsWith(":") ? "json-key" : "json-string";
|
||||
else if (match === "true" || match === "false") cls = "json-boolean";
|
||||
else if (match === "null") cls = "json-null";
|
||||
return `<span class="${cls}">${match}</span>`;
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
|
|
@ -731,49 +667,25 @@ function resetTableSearchSplitWidth() {
|
|||
<!-- Top toolbar: view toggle + document count + pagination + actions -->
|
||||
<div class="h-9 flex items-center gap-1 px-3 border-b shrink-0 text-xs text-muted-foreground">
|
||||
<div class="flex items-center border rounded-md overflow-hidden mr-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 rounded-none"
|
||||
:class="{ 'bg-accent': viewMode === 'document' }"
|
||||
:title="t('mongo.documentView')"
|
||||
@click="viewMode = 'document'"
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 rounded-none" :class="{ 'bg-accent': viewMode === 'document' }" :title="t('mongo.documentView')" @click="viewMode = 'document'">
|
||||
<Braces class="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 rounded-none"
|
||||
:class="{ 'bg-accent': viewMode === 'table' }"
|
||||
:title="t('mongo.tableView')"
|
||||
@click="viewMode = 'table'"
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 rounded-none" :class="{ 'bg-accent': viewMode === 'table' }" :title="t('mongo.tableView')" @click="viewMode = 'table'">
|
||||
<Table2 class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<span class="shrink-0 ml-1">{{ t("mongo.documents", { count: total }) }}</span>
|
||||
|
||||
<Button v-if="viewMode === 'document'" variant="ghost" size="icon" class="h-5 w-5" @click="startNew"
|
||||
><Plus class="h-3 w-3"
|
||||
/></Button>
|
||||
<Button v-if="viewMode === 'document'" variant="ghost" size="icon" class="h-5 w-5" @click="load"
|
||||
><RefreshCw class="h-3 w-3" :class="{ 'animate-spin': loading }"
|
||||
/></Button>
|
||||
<Button v-if="viewMode === 'document'" variant="ghost" size="icon" class="h-5 w-5" @click="startNew"><Plus class="h-3 w-3" /></Button>
|
||||
<Button v-if="viewMode === 'document'" variant="ghost" size="icon" class="h-5 w-5" @click="load"><RefreshCw class="h-3 w-3" :class="{ 'animate-spin': loading }" /></Button>
|
||||
|
||||
<div v-if="viewMode === 'document'" class="flex items-center gap-1 ml-1">
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="page <= 0" @click="prevPage">
|
||||
<ChevronLeft class="h-3 w-3" />
|
||||
</Button>
|
||||
<span>{{ page + 1 }} / {{ Math.max(1, Math.ceil(total / pageSize)) }}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5"
|
||||
:disabled="(page + 1) * pageSize >= total"
|
||||
@click="nextPage"
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="(page + 1) * pageSize >= total" @click="nextPage">
|
||||
<ChevronRight class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -782,92 +694,41 @@ function resetTableSearchSplitWidth() {
|
|||
|
||||
<Popover v-if="viewMode === 'table' && gridResult.columns.length">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-5 shrink-0 gap-1 px-1.5 text-xs text-foreground hover:bg-accent"
|
||||
:class="{ 'bg-accent text-foreground': (dataGridRef?.hiddenColumnCount ?? 0) > 0 }"
|
||||
:title="t('grid.columnVisibility')"
|
||||
:aria-label="t('grid.columnVisibility')"
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="h-5 shrink-0 gap-1 px-1.5 text-xs text-foreground hover:bg-accent" :class="{ 'bg-accent text-foreground': (dataGridRef?.hiddenColumnCount ?? 0) > 0 }" :title="t('grid.columnVisibility')" :aria-label="t('grid.columnVisibility')">
|
||||
<Columns3 class="h-3.5 w-3.5" />
|
||||
{{ t("grid.columnVisibility") }}
|
||||
<span v-if="(dataGridRef?.hiddenColumnCount ?? 0) > 0" class="tabular-nums">
|
||||
{{ dataGridRef?.visibleColumnCount }}/{{ dataGridRef?.displayableColumnCount }}
|
||||
</span>
|
||||
<span v-if="(dataGridRef?.hiddenColumnCount ?? 0) > 0" class="tabular-nums"> {{ dataGridRef?.visibleColumnCount }}/{{ dataGridRef?.displayableColumnCount }} </span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
class="w-64 max-w-[calc(100vw-2rem)] gap-0 overflow-hidden rounded-xl border bg-popover p-0 text-popover-foreground shadow-xl"
|
||||
@click.stop
|
||||
@keydown.stop
|
||||
>
|
||||
<PopoverContent align="end" class="w-64 max-w-[calc(100vw-2rem)] gap-0 overflow-hidden rounded-xl border bg-popover p-0 text-popover-foreground shadow-xl" @click.stop @keydown.stop>
|
||||
<div class="border-b bg-muted/40 px-2 py-1.5">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="text-xs font-semibold">{{ t("grid.columnVisibility") }}</div>
|
||||
<div class="text-[10px] text-muted-foreground tabular-nums">
|
||||
{{ dataGridRef?.visibleColumnCount ?? 0 }}/{{ dataGridRef?.displayableColumnCount ?? 0 }}
|
||||
</div>
|
||||
<div class="text-[10px] text-muted-foreground tabular-nums">{{ dataGridRef?.visibleColumnCount ?? 0 }}/{{ dataGridRef?.displayableColumnCount ?? 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 border-b px-2 py-1.5">
|
||||
<Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
v-model="columnVisibilitySearch"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="h-6 min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground"
|
||||
:placeholder="t('grid.searchColumns')"
|
||||
/>
|
||||
<input v-model="columnVisibilitySearch" autocapitalize="off" autocorrect="off" spellcheck="false" class="h-6 min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground" :placeholder="t('grid.searchColumns')" />
|
||||
</div>
|
||||
<div class="max-h-72 overflow-auto py-0.5">
|
||||
<button
|
||||
v-for="option in columnVisibilityOptions"
|
||||
:key="`${option.index}:${option.column}`"
|
||||
type="button"
|
||||
class="grid w-full grid-cols-[1.5rem_minmax(0,1fr)] items-center px-2 py-1 text-left text-xs hover:bg-accent"
|
||||
@click="dataGridRef?.toggleColumnVisibility(option.index)"
|
||||
>
|
||||
<span
|
||||
class="flex h-4 w-4 items-center justify-center rounded border"
|
||||
:class="
|
||||
dataGridRef?.isColumnVisible(option.index)
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-border bg-background text-transparent'
|
||||
"
|
||||
>
|
||||
<button v-for="option in columnVisibilityOptions" :key="`${option.index}:${option.column}`" type="button" class="grid w-full grid-cols-[1.5rem_minmax(0,1fr)] items-center px-2 py-1 text-left text-xs hover:bg-accent" @click="dataGridRef?.toggleColumnVisibility(option.index)">
|
||||
<span class="flex h-4 w-4 items-center justify-center rounded border" :class="dataGridRef?.isColumnVisible(option.index) ? 'border-primary bg-primary text-primary-foreground' : 'border-border bg-background text-transparent'">
|
||||
<Check class="h-3 w-3 stroke-[3]" />
|
||||
</span>
|
||||
<span class="truncate font-mono text-xs" :title="option.column">{{ option.column }}</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="columnVisibilityOptions.length === 0"
|
||||
class="px-2 py-6 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
<div v-if="columnVisibilityOptions.length === 0" class="px-2 py-6 text-center text-xs text-muted-foreground">
|
||||
{{ t("grid.noSearchResults") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2 border-t bg-muted/30 px-2 py-1.5">
|
||||
<span class="text-[11px] text-muted-foreground">{{ t("grid.columnVisibilityHint") }}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-xs"
|
||||
:disabled="(dataGridRef?.displayableColumnCount ?? 0) <= 1"
|
||||
@click="dataGridRef?.invertColumnVisibility()"
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="(dataGridRef?.displayableColumnCount ?? 0) <= 1" @click="dataGridRef?.invertColumnVisibility()">
|
||||
{{ t("grid.invertColumnVisibility") }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-xs"
|
||||
:disabled="(dataGridRef?.hiddenColumnCount ?? 0) === 0"
|
||||
@click="dataGridRef?.showAllColumns()"
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="(dataGridRef?.hiddenColumnCount ?? 0) === 0" @click="dataGridRef?.showAllColumns()">
|
||||
{{ t("grid.showAllColumns") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -877,42 +738,19 @@ function resetTableSearchSplitWidth() {
|
|||
|
||||
<Popover v-if="viewMode === 'table' && gridResult.columns.length">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-7 shrink-0 text-foreground hover:bg-accent"
|
||||
:class="{ 'bg-accent text-foreground': dataGridRef?.nullColumnsHidden }"
|
||||
:title="t('grid.viewOptions')"
|
||||
:aria-label="t('grid.viewOptions')"
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-7 shrink-0 text-foreground hover:bg-accent" :class="{ 'bg-accent text-foreground': dataGridRef?.nullColumnsHidden }" :title="t('grid.viewOptions')" :aria-label="t('grid.viewOptions')">
|
||||
<Wrench class="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
class="w-max min-w-44 max-w-[calc(100vw-2rem)] gap-0 overflow-hidden rounded-xl border bg-popover p-0 text-popover-foreground shadow-xl"
|
||||
@click.stop
|
||||
@keydown.stop
|
||||
>
|
||||
<PopoverContent align="end" class="w-max min-w-44 max-w-[calc(100vw-2rem)] gap-0 overflow-hidden rounded-xl border bg-popover p-0 text-popover-foreground shadow-xl" @click.stop @keydown.stop>
|
||||
<div class="border-b bg-muted/40 px-3 py-2">
|
||||
<div class="text-xs font-semibold">{{ t("grid.viewOptions") }}</div>
|
||||
</div>
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-2 px-3 py-2 text-xs hover:bg-accent"
|
||||
:class="{ 'cursor-not-allowed opacity-60': !dataGridRef?.canToggleAllNullColumns }"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-3.5 w-3.5 shrink-0 accent-primary"
|
||||
:checked="!!dataGridRef?.nullColumnsHidden"
|
||||
:disabled="!dataGridRef?.canToggleAllNullColumns"
|
||||
@change="dataGridRef?.toggleAllNullColumns()"
|
||||
/>
|
||||
<label class="flex cursor-pointer items-center gap-2 px-3 py-2 text-xs hover:bg-accent" :class="{ 'cursor-not-allowed opacity-60': !dataGridRef?.canToggleAllNullColumns }">
|
||||
<input type="checkbox" class="h-3.5 w-3.5 shrink-0 accent-primary" :checked="!!dataGridRef?.nullColumnsHidden" :disabled="!dataGridRef?.canToggleAllNullColumns" @change="dataGridRef?.toggleAllNullColumns()" />
|
||||
<span class="min-w-0 flex items-center gap-1 font-medium">
|
||||
{{ t("grid.hideNullColumns") }}
|
||||
<span v-if="(dataGridRef?.allNullColumnCount ?? 0) > 0" class="text-muted-foreground tabular-nums">
|
||||
({{ dataGridRef?.allNullColumnCount }})
|
||||
</span>
|
||||
<span v-if="(dataGridRef?.allNullColumnCount ?? 0) > 0" class="text-muted-foreground tabular-nums"> ({{ dataGridRef?.allNullColumnCount }}) </span>
|
||||
</span>
|
||||
</label>
|
||||
</PopoverContent>
|
||||
|
|
@ -937,19 +775,7 @@ function resetTableSearchSplitWidth() {
|
|||
@reload="load"
|
||||
@paginate="(offset: number, limit: number) => paginate(offset, limit)"
|
||||
>
|
||||
<template
|
||||
#search-bar="{
|
||||
localFilterCount,
|
||||
hasLocalColumnFilters,
|
||||
localFilterSummaries,
|
||||
clearLocalFilter,
|
||||
}: {
|
||||
localFilterCount: number;
|
||||
hasLocalColumnFilters: boolean;
|
||||
localFilterSummaries: LocalFilterSummary[];
|
||||
clearLocalFilter: (columnIndex?: number) => void;
|
||||
}"
|
||||
>
|
||||
<template #search-bar="{ localFilterCount, hasLocalColumnFilters, localFilterSummaries, clearLocalFilter }: { localFilterCount: number; hasLocalColumnFilters: boolean; localFilterSummaries: LocalFilterSummary[]; clearLocalFilter: (columnIndex?: number) => void }">
|
||||
<div ref="tableSearchSplitContainerRef" class="flex flex-1 min-w-0">
|
||||
<div class="flex flex-1 items-center gap-1 px-2 py-0.5 min-w-0" :style="tableFindPaneStyle">
|
||||
<Popover v-model:open="mongoFilterBuilderOpen">
|
||||
|
|
@ -957,18 +783,11 @@ function resetTableSearchSplitWidth() {
|
|||
<button
|
||||
type="button"
|
||||
class="relative flex h-5 w-5 shrink-0 items-center justify-center rounded border text-[11px] font-medium transition-colors"
|
||||
:class="
|
||||
hasLocalColumnFilters || appliedMongoFilter
|
||||
? 'border-primary/40 bg-primary/10 text-primary hover:bg-primary/15'
|
||||
: 'border-border/70 text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
"
|
||||
:class="hasLocalColumnFilters || appliedMongoFilter ? 'border-primary/40 bg-primary/10 text-primary hover:bg-primary/15' : 'border-border/70 text-muted-foreground hover:bg-accent hover:text-foreground'"
|
||||
@click="ensureMongoFilterRule"
|
||||
>
|
||||
<Filter class="h-3 w-3" />
|
||||
<span
|
||||
v-if="localFilterCount + mongoStructuredFilterCount"
|
||||
class="absolute -right-1 -top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-primary px-1 text-[9px] leading-none text-primary-foreground"
|
||||
>
|
||||
<span v-if="localFilterCount + mongoStructuredFilterCount" class="absolute -right-1 -top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-primary px-1 text-[9px] leading-none text-primary-foreground">
|
||||
{{ localFilterCount + mongoStructuredFilterCount }}
|
||||
</span>
|
||||
</button>
|
||||
|
|
@ -981,10 +800,7 @@ function resetTableSearchSplitWidth() {
|
|||
{{ t("grid.filterBuilderAddRule") }}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="hasLocalColumnFilters"
|
||||
class="space-y-2 rounded-md border border-primary/20 bg-primary/5 px-2.5 py-2"
|
||||
>
|
||||
<div v-if="hasLocalColumnFilters" class="space-y-2 rounded-md border border-primary/20 bg-primary/5 px-2.5 py-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex min-w-0 items-center gap-2 text-xs font-medium text-primary">
|
||||
<Filter class="h-3.5 w-3.5 shrink-0" />
|
||||
|
|
@ -996,11 +812,7 @@ function resetTableSearchSplitWidth() {
|
|||
</Button>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="summary in localFilterSummaries"
|
||||
:key="summary.columnIndex"
|
||||
class="grid grid-cols-[minmax(0,0.9fr)_minmax(0,1.6fr)_auto] items-center gap-2 rounded border border-primary/10 bg-background/70 px-2 py-1 text-xs"
|
||||
>
|
||||
<div v-for="summary in localFilterSummaries" :key="summary.columnIndex" class="grid grid-cols-[minmax(0,0.9fr)_minmax(0,1.6fr)_auto] items-center gap-2 rounded border border-primary/10 bg-background/70 px-2 py-1 text-xs">
|
||||
<span class="truncate font-medium text-foreground" :title="summary.columnName">
|
||||
{{ summary.columnName }}
|
||||
</span>
|
||||
|
|
@ -1013,13 +825,7 @@ function resetTableSearchSplitWidth() {
|
|||
{{ t("grid.localFilterMoreValues", { count: summary.hiddenValueCount }) }}
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||||
:title="t('grid.clearFilter')"
|
||||
@click="clearLocalFilter(summary.columnIndex)"
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6 text-muted-foreground hover:text-destructive" :title="t('grid.clearFilter')" @click="clearLocalFilter(summary.columnIndex)">
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -1042,18 +848,9 @@ function resetTableSearchSplitWidth() {
|
|||
{{ rule.conjunction }}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-[minmax(0,1fr)_minmax(0,0.95fr)_minmax(0,1fr)_auto] items-center gap-1.5"
|
||||
>
|
||||
<Select
|
||||
:model-value="rule.fieldName"
|
||||
@update:model-value="
|
||||
(value: any) => updateMongoFilterRule(rule.id, { fieldName: String(value) })
|
||||
"
|
||||
>
|
||||
<SelectTrigger
|
||||
class="h-8 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate"
|
||||
>
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,0.95fr)_minmax(0,1fr)_auto] items-center gap-1.5">
|
||||
<Select :model-value="rule.fieldName" @update:model-value="(value: any) => updateMongoFilterRule(rule.id, { fieldName: String(value) })">
|
||||
<SelectTrigger class="h-8 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate">
|
||||
<SelectValue :placeholder="t('grid.filterBuilderColumn')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
|
|
@ -1063,23 +860,12 @@ function resetTableSearchSplitWidth() {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
:model-value="rule.mode"
|
||||
@update:model-value="
|
||||
(value: any) => updateMongoFilterRule(rule.id, { mode: value as MongoFilterMode })
|
||||
"
|
||||
>
|
||||
<SelectTrigger
|
||||
class="h-8 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate"
|
||||
>
|
||||
<Select :model-value="rule.mode" @update:model-value="(value: any) => updateMongoFilterRule(rule.id, { mode: value as MongoFilterMode })">
|
||||
<SelectTrigger class="h-8 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem
|
||||
v-for="option in mongoFilterModeOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
<SelectItem v-for="option in mongoFilterModeOptions" :key="option.value" :value="option.value">
|
||||
{{ t(option.labelKey) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
|
|
@ -1090,25 +876,14 @@ function resetTableSearchSplitWidth() {
|
|||
:model-value="rule.rawValue"
|
||||
class="h-8 min-w-0 text-xs"
|
||||
:placeholder="t('grid.filterBuilderValue')"
|
||||
@update:model-value="
|
||||
(value) => updateMongoFilterRule(rule.id, { rawValue: String(value ?? '') })
|
||||
"
|
||||
@update:model-value="(value) => updateMongoFilterRule(rule.id, { rawValue: String(value ?? '') })"
|
||||
@keydown.enter.prevent="applyMongoStructuredFilters"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-8 min-w-0 items-center overflow-hidden rounded-md border border-dashed px-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<div v-else class="flex h-8 min-w-0 items-center overflow-hidden rounded-md border border-dashed px-2 text-xs text-muted-foreground">
|
||||
<span class="truncate">{{ t("grid.filterBuilderNoValue") }}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
:disabled="mongoFilterRules.length === 1"
|
||||
@click="removeMongoFilterRule(rule.id)"
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive" :disabled="mongoFilterRules.length === 1" @click="removeMongoFilterRule(rule.id)">
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -1119,12 +894,7 @@ function resetTableSearchSplitWidth() {
|
|||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-2 pt-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 px-2 text-xs"
|
||||
@click="clearMongoFilters(clearLocalFilter)"
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs" @click="clearMongoFilters(clearLocalFilter)">
|
||||
{{ t("grid.clearFilter") }}
|
||||
</Button>
|
||||
<div class="flex items-center gap-2">
|
||||
|
|
@ -1139,15 +909,7 @@ function resetTableSearchSplitWidth() {
|
|||
</PopoverContent>
|
||||
</Popover>
|
||||
<span class="text-blue-600 dark:text-blue-400 text-xs font-medium select-none shrink-0">find</span>
|
||||
<input
|
||||
v-model="filterInput"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="flex-1 h-5 min-w-0 text-xs bg-transparent outline-none placeholder:text-muted-foreground/60 font-mono"
|
||||
placeholder="{}"
|
||||
@keydown.enter="applyFilter"
|
||||
/>
|
||||
<input v-model="filterInput" autocapitalize="off" autocorrect="off" spellcheck="false" class="flex-1 h-5 min-w-0 text-xs bg-transparent outline-none placeholder:text-muted-foreground/60 font-mono" placeholder="{}" @keydown.enter="applyFilter" />
|
||||
<button
|
||||
v-if="filterInput.trim()"
|
||||
class="text-muted-foreground hover:text-foreground shrink-0"
|
||||
|
|
@ -1170,15 +932,7 @@ function resetTableSearchSplitWidth() {
|
|||
</button>
|
||||
<div class="flex flex-1 items-center gap-1 px-2 py-0.5 min-w-0">
|
||||
<span class="text-orange-600 dark:text-orange-400 text-xs font-medium select-none shrink-0">sort</span>
|
||||
<input
|
||||
v-model="sortInput"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="flex-1 h-5 min-w-0 text-xs bg-transparent outline-none placeholder:text-muted-foreground/60 font-mono"
|
||||
placeholder="{}"
|
||||
@keydown.enter="applyFilter"
|
||||
/>
|
||||
<input v-model="sortInput" autocapitalize="off" autocorrect="off" spellcheck="false" class="flex-1 h-5 min-w-0 text-xs bg-transparent outline-none placeholder:text-muted-foreground/60 font-mono" placeholder="{}" @keydown.enter="applyFilter" />
|
||||
<button
|
||||
v-if="sortInput.trim()"
|
||||
class="text-muted-foreground hover:text-foreground shrink-0"
|
||||
|
|
@ -1207,20 +961,9 @@ function resetTableSearchSplitWidth() {
|
|||
<Pane :size="30" :min-size="15" :max-size="50">
|
||||
<div class="h-full flex flex-col overflow-hidden">
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div
|
||||
v-for="(doc, idx) in documents"
|
||||
:key="idx"
|
||||
class="px-3 py-1.5 border-b text-xs font-mono cursor-pointer hover:bg-accent/50 flex items-center gap-2 group"
|
||||
:class="{ 'bg-accent': selectedIdx === idx }"
|
||||
@click="selectDoc(idx)"
|
||||
>
|
||||
<div v-for="(doc, idx) in documents" :key="idx" class="px-3 py-1.5 border-b text-xs font-mono cursor-pointer hover:bg-accent/50 flex items-center gap-2 group" :class="{ 'bg-accent': selectedIdx === idx }" @click="selectDoc(idx)">
|
||||
<span class="truncate flex-1">{{ docPreview(doc) }}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive shrink-0"
|
||||
@click.stop="requestDeleteDoc(idx)"
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive shrink-0" @click.stop="requestDeleteDoc(idx)">
|
||||
<Trash2 class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -1238,51 +981,28 @@ function resetTableSearchSplitWidth() {
|
|||
<div class="h-9 flex items-center gap-2 px-4 border-b bg-muted/30 shrink-0">
|
||||
<Badge variant="secondary" class="text-xs">{{ isNew ? "New" : selectedDoc?._id }}</Badge>
|
||||
<span class="flex-1" />
|
||||
<Button v-if="!isEditing" variant="ghost" size="sm" class="h-6 text-xs" @click="startEdit">{{
|
||||
t("mongo.edit")
|
||||
}}</Button>
|
||||
<Button v-if="!isEditing" variant="ghost" size="sm" class="h-6 text-xs" @click="startEdit">{{ t("mongo.edit") }}</Button>
|
||||
<template v-if="isEditing">
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="addField">
|
||||
<Plus class="w-3 h-3 mr-1" /> {{ t("mongo.addField") }}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="cancelEdit">{{
|
||||
t("grid.discard")
|
||||
}}</Button>
|
||||
<Button size="sm" class="h-6 text-xs" @click="saveDoc"
|
||||
><Save class="w-3 h-3 mr-1" />{{ t("grid.save") }}</Button
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="addField"> <Plus class="w-3 h-3 mr-1" /> {{ t("mongo.addField") }} </Button>
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="cancelEdit">{{ t("grid.discard") }}</Button>
|
||||
<Button size="sm" class="h-6 text-xs" @click="saveDoc"><Save class="w-3 h-3 mr-1" />{{ t("grid.save") }}</Button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="isEditing" class="flex-1 overflow-auto bg-muted/10">
|
||||
<div
|
||||
class="json-edit min-w-fit p-5 font-mono text-[13px] leading-6"
|
||||
:style="{ '--mongo-key-width': editKeyWidth }"
|
||||
>
|
||||
<div class="json-edit min-w-fit p-5 font-mono text-[13px] leading-6" :style="{ '--mongo-key-width': editKeyWidth }">
|
||||
<div class="json-edit-brace">{</div>
|
||||
|
||||
<JsonEditNode
|
||||
v-for="(field, idx) in editFields"
|
||||
:key="field.key"
|
||||
:node="field"
|
||||
parent-kind="root"
|
||||
:removable="!field.readonlyValue"
|
||||
@remove="requestRemoveField(idx)"
|
||||
/>
|
||||
<JsonEditNode v-for="(field, idx) in editFields" :key="field.key" :node="field" parent-kind="root" :removable="!field.readonlyValue" @remove="requestRemoveField(idx)" />
|
||||
|
||||
<Button variant="ghost" size="sm" class="json-edit-add" @click="addField">
|
||||
<Plus class="w-3 h-3 mr-1" /> {{ t("mongo.addField") }}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" class="json-edit-add" @click="addField"> <Plus class="w-3 h-3 mr-1" /> {{ t("mongo.addField") }} </Button>
|
||||
|
||||
<div class="json-edit-brace">}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex-1 overflow-auto bg-muted/10">
|
||||
<pre
|
||||
class="json-viewer min-w-fit p-5 font-mono text-[13px] leading-6"
|
||||
v-html="highlightedJson(editJson)"
|
||||
/>
|
||||
<pre class="json-viewer min-w-fit p-5 font-mono text-[13px] leading-6" v-html="highlightedJson(editJson)" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
|
|
@ -1292,13 +1012,7 @@ function resetTableSearchSplitWidth() {
|
|||
<div v-if="error" class="px-3 py-1.5 border-t bg-destructive/10 text-destructive text-xs shrink-0">
|
||||
{{ error }}
|
||||
</div>
|
||||
<DangerConfirmDialog
|
||||
v-model:open="showDeleteConfirm"
|
||||
:message="t('dangerDialog.deleteMessage')"
|
||||
:details="deleteDetails"
|
||||
:confirm-label="t('dangerDialog.deleteConfirm')"
|
||||
@confirm="confirmDelete"
|
||||
/>
|
||||
<DangerConfirmDialog v-model:open="showDeleteConfirm" :message="t('dangerDialog.deleteMessage')" :details="deleteDetails" :confirm-label="t('dangerDialog.deleteConfirm')" @confirm="confirmDelete" />
|
||||
</div>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
|
|
|
|||
|
|
@ -46,28 +46,12 @@ import ProcedureExecutionDialog from "@/components/objects/ProcedureExecutionDia
|
|||
import * as api from "@/lib/api";
|
||||
import type { ConnectionConfig, ObjectInfo, ObjectSourceKind } from "@/types/database";
|
||||
import { isSchemaAware } from "@/lib/databaseCapabilities";
|
||||
import {
|
||||
supportsSchemaDiagram,
|
||||
supportsTableImport,
|
||||
supportsTableStructureEditing,
|
||||
supportsTableTruncate,
|
||||
} from "@/lib/databaseFeatureSupport";
|
||||
import { supportsSchemaDiagram, supportsTableImport, supportsTableStructureEditing, supportsTableTruncate } from "@/lib/databaseFeatureSupport";
|
||||
import { connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect";
|
||||
import { buildTableSelectSql } from "@/lib/tableSelectSql";
|
||||
import {
|
||||
buildDropObjectSql,
|
||||
buildDuplicateTableStructureSql,
|
||||
buildEmptyTableSql,
|
||||
buildTruncateTableSql,
|
||||
type TableAdminSqlOptions,
|
||||
} from "@/lib/dbAdminSql";
|
||||
import { buildDropObjectSql, buildDuplicateTableStructureSql, buildEmptyTableSql, buildTruncateTableSql, type TableAdminSqlOptions } from "@/lib/dbAdminSql";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import {
|
||||
buildExecutableObjectSourceStatements,
|
||||
buildRoutineRenameObjectSourceStatements,
|
||||
objectSourceSaveExecutionMode,
|
||||
supportsSourceBackedRoutineRename,
|
||||
} from "@/lib/objectSourceEditor";
|
||||
import { buildExecutableObjectSourceStatements, buildRoutineRenameObjectSourceStatements, objectSourceSaveExecutionMode, supportsSourceBackedRoutineRename } from "@/lib/objectSourceEditor";
|
||||
import { buildRenameObjectSql, supportsObjectRename } from "@/lib/objectRenameSql";
|
||||
import { buildViewDdl } from "@/lib/viewDdl";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
|
|
@ -83,16 +67,7 @@ import QueryEditor from "@/components/editor/QueryEditor.vue";
|
|||
import type { SqlFormatDialect } from "@/lib/sqlFormatter";
|
||||
import { isCancelSearchShortcut } from "@/lib/keyboardShortcuts";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import {
|
||||
buildObjectBrowserRows,
|
||||
filterObjectBrowserRows,
|
||||
formatObjectBrowserTimestamp,
|
||||
initialObjectBrowserSortDirection,
|
||||
sortObjectBrowserRows,
|
||||
type ObjectBrowserRow,
|
||||
type ObjectBrowserSortDirection,
|
||||
type ObjectBrowserSortKey,
|
||||
} from "@/lib/objectBrowserRows";
|
||||
import { buildObjectBrowserRows, filterObjectBrowserRows, formatObjectBrowserTimestamp, initialObjectBrowserSortDirection, sortObjectBrowserRows, type ObjectBrowserRow, type ObjectBrowserSortDirection, type ObjectBrowserSortKey } from "@/lib/objectBrowserRows";
|
||||
|
||||
type ObjectFilter = "all" | "tables" | "views" | "procedures" | "functions" | "sequences" | "packages";
|
||||
|
||||
|
|
@ -130,9 +105,7 @@ const sourceContent = ref("");
|
|||
const sourceError = ref("");
|
||||
const sourceRow = ref<ObjectBrowserRow | null>(null);
|
||||
const sourceEditing = ref(false);
|
||||
const effectiveDatabaseType = computed(
|
||||
() => effectiveDatabaseTypeForConnection(props.connection) ?? props.connection.db_type,
|
||||
);
|
||||
const effectiveDatabaseType = computed(() => effectiveDatabaseTypeForConnection(props.connection) ?? props.connection.db_type);
|
||||
const sourceDraft = ref("");
|
||||
const sourceSaving = ref(false);
|
||||
const sourceSaveError = ref("");
|
||||
|
|
@ -164,29 +137,19 @@ let loadId = 0;
|
|||
// Export via background tracker
|
||||
const { addTask: addExportTask } = useExportTracker();
|
||||
|
||||
const needsSchema = computed(
|
||||
() => isSchemaAware(props.connection.db_type) && !connectionUsesDatabaseObjectTreeMode(props.connection),
|
||||
);
|
||||
const needsSchema = computed(() => isSchemaAware(props.connection.db_type) && !connectionUsesDatabaseObjectTreeMode(props.connection));
|
||||
const tableCount = computed(() => rows.value.filter((row) => row.type === "TABLE").length);
|
||||
const viewCount = computed(() => rows.value.filter((row) => row.type === "VIEW").length);
|
||||
const procedureCount = computed(() => rows.value.filter((row) => row.type === "PROCEDURE").length);
|
||||
const functionCount = computed(() => rows.value.filter((row) => row.type === "FUNCTION").length);
|
||||
const sequenceCount = computed(() => rows.value.filter((row) => row.type === "SEQUENCE").length);
|
||||
const packageCount = computed(
|
||||
() => rows.value.filter((row) => row.type === "PACKAGE" || row.type === "PACKAGE_BODY").length,
|
||||
);
|
||||
const packageCount = computed(() => rows.value.filter((row) => row.type === "PACKAGE" || row.type === "PACKAGE_BODY").length);
|
||||
const canOpenStructureEditor = computed(() => supportsTableStructureEditing(effectiveDatabaseType.value));
|
||||
const canOpenDiagram = computed(() => !!props.database && supportsSchemaDiagram(effectiveDatabaseType.value));
|
||||
const canOpenTableImport = computed(() => !!props.database && supportsTableImport(effectiveDatabaseType.value));
|
||||
const supportsTruncateTable = computed(() => supportsTableTruncate(effectiveDatabaseType.value));
|
||||
const sourceDialect = computed<"mysql" | "postgres" | "sqlserver">(() => {
|
||||
if (
|
||||
effectiveDatabaseType.value === "postgres" ||
|
||||
effectiveDatabaseType.value === "gaussdb" ||
|
||||
effectiveDatabaseType.value === "kwdb" ||
|
||||
effectiveDatabaseType.value === "opengauss"
|
||||
)
|
||||
return "postgres";
|
||||
if (effectiveDatabaseType.value === "postgres" || effectiveDatabaseType.value === "gaussdb" || effectiveDatabaseType.value === "kwdb" || effectiveDatabaseType.value === "opengauss") return "postgres";
|
||||
if (effectiveDatabaseType.value === "sqlserver") return "sqlserver";
|
||||
return "mysql";
|
||||
});
|
||||
|
|
@ -252,11 +215,7 @@ const selectedTableRows = computed(() => {
|
|||
return selectableRows.value.filter((row) => ids.has(row.id));
|
||||
});
|
||||
const selectedTableCount = computed(() => selectedTableRows.value.length);
|
||||
const allVisibleTablesSelected = computed(
|
||||
() =>
|
||||
visibleSelectableRows.value.length > 0 &&
|
||||
visibleSelectableRows.value.every((row) => selectedTableIds.value.has(row.id)),
|
||||
);
|
||||
const allVisibleTablesSelected = computed(() => visibleSelectableRows.value.length > 0 && visibleSelectableRows.value.every((row) => selectedTableIds.value.has(row.id)));
|
||||
|
||||
function iconFor(row: ObjectBrowserRow) {
|
||||
if (row.type === "VIEW") return Eye;
|
||||
|
|
@ -307,9 +266,7 @@ function groupedFilteredRows() {
|
|||
const candidateIds = new Set(candidateRows.map((row) => row.id));
|
||||
const matchingRows = filterObjectBrowserRows(candidateRows, query);
|
||||
const matchingIds = new Set(matchingRows.map((row) => row.id));
|
||||
const parentIdsWithMatchingPartitions = new Set(
|
||||
matchingRows.flatMap((row) => (row.partitionParentId ? [row.partitionParentId] : [])),
|
||||
);
|
||||
const parentIdsWithMatchingPartitions = new Set(matchingRows.flatMap((row) => (row.partitionParentId ? [row.partitionParentId] : [])));
|
||||
const rootRows = candidateRows.filter((row) => {
|
||||
if (row.partitionParentId) return false;
|
||||
if (!query) return true;
|
||||
|
|
@ -325,8 +282,7 @@ function groupedFilteredRows() {
|
|||
const parentMatches = matchingIds.has(row.id);
|
||||
const shouldShowPartitions = expandedPartitionParentIds.value.has(row.id) || !!query;
|
||||
if (!shouldShowPartitions) continue;
|
||||
const visiblePartitions =
|
||||
query && !parentMatches ? partitions.filter((partition) => matchingIds.has(partition.id)) : partitions;
|
||||
const visiblePartitions = query && !parentMatches ? partitions.filter((partition) => matchingIds.has(partition.id)) : partitions;
|
||||
result.push(...sortObjectBrowserRows(visiblePartitions, sortKey.value, sortDirection.value));
|
||||
}
|
||||
|
||||
|
|
@ -355,21 +311,11 @@ function togglePartitionParent(row: ObjectBrowserRow) {
|
|||
}
|
||||
|
||||
function canOpenSource(row: ObjectBrowserRow) {
|
||||
return (
|
||||
row.type === "VIEW" ||
|
||||
row.type === "PROCEDURE" ||
|
||||
row.type === "FUNCTION" ||
|
||||
row.type === "SEQUENCE" ||
|
||||
row.type === "PACKAGE" ||
|
||||
row.type === "PACKAGE_BODY"
|
||||
);
|
||||
return row.type === "VIEW" || row.type === "PROCEDURE" || row.type === "FUNCTION" || row.type === "SEQUENCE" || row.type === "PACKAGE" || row.type === "PACKAGE_BODY";
|
||||
}
|
||||
|
||||
function canRename(row: ObjectBrowserRow) {
|
||||
return (
|
||||
supportsObjectRename(effectiveDatabaseType.value, row.type) ||
|
||||
supportsSourceBackedRoutineRename(effectiveDatabaseType.value, row.type as ObjectSourceKind)
|
||||
);
|
||||
return supportsObjectRename(effectiveDatabaseType.value, row.type) || supportsSourceBackedRoutineRename(effectiveDatabaseType.value, row.type as ObjectSourceKind);
|
||||
}
|
||||
|
||||
function sourceTitle(row: ObjectBrowserRow | null) {
|
||||
|
|
@ -405,13 +351,7 @@ async function openSource(row: ObjectBrowserRow) {
|
|||
sourceSaveError.value = "";
|
||||
sourceLoading.value = true;
|
||||
try {
|
||||
const result = await api.getObjectSource(
|
||||
props.connection.id,
|
||||
props.database,
|
||||
row.schema || selectedSchema.value || props.database,
|
||||
row.name,
|
||||
row.type as ObjectSourceKind,
|
||||
);
|
||||
const result = await api.getObjectSource(props.connection.id, props.database, row.schema || selectedSchema.value || props.database, row.name, row.type as ObjectSourceKind);
|
||||
sourceContent.value = result.source;
|
||||
sourceDraft.value = result.source;
|
||||
sourceEditing.value = row.type !== "SEQUENCE";
|
||||
|
|
@ -425,13 +365,7 @@ async function openSource(row: ObjectBrowserRow) {
|
|||
async function openViewDdl(row: ObjectBrowserRow) {
|
||||
if (row.type !== "VIEW") return;
|
||||
try {
|
||||
const result = await api.getObjectSource(
|
||||
props.connection.id,
|
||||
props.database,
|
||||
row.schema || selectedSchema.value || props.database,
|
||||
row.name,
|
||||
"VIEW",
|
||||
);
|
||||
const result = await api.getObjectSource(props.connection.id, props.database, row.schema || selectedSchema.value || props.database, row.name, "VIEW");
|
||||
const ddl = await buildViewDdl({
|
||||
databaseType: effectiveDatabaseType.value,
|
||||
schema: row.schema || selectedSchema.value || props.database,
|
||||
|
|
@ -534,13 +468,7 @@ async function confirmRename() {
|
|||
try {
|
||||
const schema = row.schema || selectedSchema.value || props.database;
|
||||
if (supportsSourceBackedRoutineRename(effectiveDatabaseType.value, row.type as ObjectSourceKind)) {
|
||||
const source = await api.getObjectSource(
|
||||
props.connection.id,
|
||||
props.database,
|
||||
schema,
|
||||
row.name,
|
||||
row.type as ObjectSourceKind,
|
||||
);
|
||||
const source = await api.getObjectSource(props.connection.id, props.database, schema, row.name, row.type as ObjectSourceKind);
|
||||
const statements = await buildRoutineRenameObjectSourceStatements({
|
||||
databaseType: effectiveDatabaseType.value,
|
||||
objectType: row.type as ObjectSourceKind,
|
||||
|
|
@ -566,11 +494,7 @@ async function confirmRename() {
|
|||
showRenameDialog.value = false;
|
||||
if (sourceRow.value?.id === row.id) closeSource();
|
||||
await reload();
|
||||
await connectionStore.refreshObjectListTreeNode(
|
||||
props.connection.id,
|
||||
props.database,
|
||||
row.schema || selectedSchema.value,
|
||||
);
|
||||
await connectionStore.refreshObjectListTreeNode(props.connection.id, props.database, row.schema || selectedSchema.value);
|
||||
} catch (e: any) {
|
||||
renameError.value = e?.message || String(e);
|
||||
}
|
||||
|
|
@ -587,21 +511,10 @@ async function confirmDrop() {
|
|||
name: row.name,
|
||||
});
|
||||
await api.executeQuery(props.connection.id, props.database, sql);
|
||||
const successKey =
|
||||
row.type === "VIEW"
|
||||
? "contextMenu.dropViewSuccess"
|
||||
: row.type === "PROCEDURE"
|
||||
? "contextMenu.dropProcedureSuccess"
|
||||
: row.type === "FUNCTION"
|
||||
? "contextMenu.dropFunctionSuccess"
|
||||
: "contextMenu.dropTableSuccess";
|
||||
const successKey = row.type === "VIEW" ? "contextMenu.dropViewSuccess" : row.type === "PROCEDURE" ? "contextMenu.dropProcedureSuccess" : row.type === "FUNCTION" ? "contextMenu.dropFunctionSuccess" : "contextMenu.dropTableSuccess";
|
||||
toast(t(successKey, { name: row.name }));
|
||||
await reload();
|
||||
await connectionStore.refreshObjectListTreeNode(
|
||||
props.connection.id,
|
||||
props.database,
|
||||
row.schema || selectedSchema.value,
|
||||
);
|
||||
await connectionStore.refreshObjectListTreeNode(props.connection.id, props.database, row.schema || selectedSchema.value);
|
||||
} catch (e: any) {
|
||||
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
|
|
@ -798,17 +711,8 @@ async function exportStructure(row: ObjectBrowserRow) {
|
|||
async function exportDataLegacy(row: ObjectBrowserRow, format: "json" | "sql") {
|
||||
try {
|
||||
const schema = row.schema || selectedSchema.value;
|
||||
const tableColumns =
|
||||
format === "sql"
|
||||
? await api.getColumns(props.connection.id, props.database, schema || props.database, row.name)
|
||||
: undefined;
|
||||
const queryColumns =
|
||||
props.connection.db_type === "neo4j"
|
||||
? (
|
||||
tableColumns ??
|
||||
(await api.getColumns(props.connection.id, props.database, schema || props.database, row.name))
|
||||
).map((column) => column.name)
|
||||
: undefined;
|
||||
const tableColumns = format === "sql" ? await api.getColumns(props.connection.id, props.database, schema || props.database, row.name) : undefined;
|
||||
const queryColumns = props.connection.db_type === "neo4j" ? (tableColumns ?? (await api.getColumns(props.connection.id, props.database, schema || props.database, row.name))).map((column) => column.name) : undefined;
|
||||
const result = await fetchTableDataForExport({
|
||||
databaseType: effectiveDatabaseType.value,
|
||||
schema,
|
||||
|
|
@ -848,10 +752,7 @@ async function exportDataLegacy(row: ObjectBrowserRow, format: "json" | "sql") {
|
|||
}
|
||||
}
|
||||
|
||||
function columnTypesForResultColumns(
|
||||
columns: string[],
|
||||
tableColumns: Array<{ name: string; data_type: string }>,
|
||||
): Array<string | undefined> {
|
||||
function columnTypesForResultColumns(columns: string[], tableColumns: Array<{ name: string; data_type: string }>): Array<string | undefined> {
|
||||
const typesByName = new Map(tableColumns.map((column) => [column.name.toLocaleLowerCase(), column.data_type]));
|
||||
return columns.map((column) => typesByName.get(column.toLocaleLowerCase()));
|
||||
}
|
||||
|
|
@ -896,12 +797,7 @@ async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx") {
|
|||
|
||||
let task: ExportTask | null = null;
|
||||
try {
|
||||
const queryColumns =
|
||||
props.connection.db_type === "neo4j"
|
||||
? (await api.getColumns(props.connection.id, props.database, schema || props.database, row.name)).map(
|
||||
(column) => column.name,
|
||||
)
|
||||
: undefined;
|
||||
const queryColumns = props.connection.db_type === "neo4j" ? (await api.getColumns(props.connection.id, props.database, schema || props.database, row.name)).map((column) => column.name) : undefined;
|
||||
|
||||
task = addExportTask(row.name, format, filePath);
|
||||
const currentTask = task;
|
||||
|
|
@ -1118,11 +1014,7 @@ async function loadObjects() {
|
|||
});
|
||||
const availableTableIds = new Set(rows.value.filter((row) => row.type === "TABLE").map((row) => row.id));
|
||||
setSelectedTableIds(new Set([...selectedTableIds.value].filter((id) => availableTableIds.has(id))));
|
||||
expandedPartitionParentIds.value = new Set(
|
||||
[...expandedPartitionParentIds.value].filter((id) =>
|
||||
rows.value.some((row) => row.id === id && row.partitionCount),
|
||||
),
|
||||
);
|
||||
expandedPartitionParentIds.value = new Set([...expandedPartitionParentIds.value].filter((id) => rows.value.some((row) => row.id === id && row.partitionCount)));
|
||||
} catch (e: any) {
|
||||
if (id !== loadId) return;
|
||||
error.value = e?.message || String(e);
|
||||
|
|
@ -1160,20 +1052,7 @@ function filterCount(filter: ObjectFilter) {
|
|||
}
|
||||
|
||||
function filterLabel(filter: ObjectFilter) {
|
||||
const key =
|
||||
filter === "tables"
|
||||
? "objects.tables"
|
||||
: filter === "views"
|
||||
? "objects.views"
|
||||
: filter === "procedures"
|
||||
? "objects.procedures"
|
||||
: filter === "functions"
|
||||
? "objects.functions"
|
||||
: filter === "sequences"
|
||||
? "objects.sequences"
|
||||
: filter === "packages"
|
||||
? "objects.packages"
|
||||
: "objects.all";
|
||||
const key = filter === "tables" ? "objects.tables" : filter === "views" ? "objects.views" : filter === "procedures" ? "objects.procedures" : filter === "functions" ? "objects.functions" : filter === "sequences" ? "objects.sequences" : filter === "packages" ? "objects.packages" : "objects.all";
|
||||
return `${t(key)} ${filterCount(filter)}`;
|
||||
}
|
||||
|
||||
|
|
@ -1227,17 +1106,11 @@ function exportDataSubmenu(item: ObjectBrowserRow): ContextMenuItem {
|
|||
function getTableMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||||
return [
|
||||
{ label: t("contextMenu.viewData"), action: () => openRow(item), icon: Table2 },
|
||||
...(canOpenStructureEditor.value
|
||||
? [{ label: t("contextMenu.editStructure"), action: () => openStructureEditor(item), icon: PencilRuler }]
|
||||
: []),
|
||||
...(canRename(item)
|
||||
? [{ label: t("contextMenu.renameObject"), action: () => requestRename(item), icon: Pencil }]
|
||||
: []),
|
||||
...(canOpenStructureEditor.value ? [{ label: t("contextMenu.editStructure"), action: () => openStructureEditor(item), icon: PencilRuler }] : []),
|
||||
...(canRename(item) ? [{ label: t("contextMenu.renameObject"), action: () => requestRename(item), icon: Pencil }] : []),
|
||||
{ label: t("contextMenu.newQuery"), action: () => openNewQuery(item), icon: TerminalSquare },
|
||||
...(canOpenDiagram.value ? [{ label: t("diagram.open"), action: () => openDiagram(item), icon: Network }] : []),
|
||||
...(canOpenTableImport.value
|
||||
? [{ label: t("contextMenu.importData"), action: () => openTableImport(item), icon: Download }]
|
||||
: []),
|
||||
...(canOpenTableImport.value ? [{ label: t("contextMenu.importData"), action: () => openTableImport(item), icon: Download }] : []),
|
||||
{ label: t("dataCompare.title"), action: () => openDataCompare(item), icon: ArrowRightLeft },
|
||||
{ label: "", separator: true },
|
||||
exportDataSubmenu(item),
|
||||
|
|
@ -1279,9 +1152,7 @@ function getViewMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
|||
{ label: t("contextMenu.editView"), action: () => openSource(item), icon: PencilLine },
|
||||
{ label: t("contextMenu.viewSource"), action: () => openSource(item), icon: Code2 },
|
||||
{ label: t("contextMenu.viewDdl"), action: () => openViewDdl(item), icon: ScrollText },
|
||||
...(canRename(item)
|
||||
? [{ label: t("contextMenu.renameObject"), action: () => requestRename(item), icon: Pencil }]
|
||||
: []),
|
||||
...(canRename(item) ? [{ label: t("contextMenu.renameObject"), action: () => requestRename(item), icon: Pencil }] : []),
|
||||
{ label: t("contextMenu.newQuery"), action: () => openNewQuery(item), icon: TerminalSquare },
|
||||
...(canOpenDiagram.value ? [{ label: t("diagram.open"), action: () => openDiagram(item), icon: Network }] : []),
|
||||
{ label: "", separator: true },
|
||||
|
|
@ -1302,13 +1173,9 @@ function getViewMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
|||
|
||||
function getProcFuncMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||||
return [
|
||||
...(item.type === "PROCEDURE"
|
||||
? [{ label: t("contextMenu.executeProcedure"), action: () => openProcedureExecution(item), icon: Play }]
|
||||
: []),
|
||||
...(item.type === "PROCEDURE" ? [{ label: t("contextMenu.executeProcedure"), action: () => openProcedureExecution(item), icon: Play }] : []),
|
||||
{ label: t("contextMenu.viewSource"), action: () => openSource(item), icon: Code2 },
|
||||
...(canRename(item)
|
||||
? [{ label: t("contextMenu.renameObject"), action: () => requestRename(item), icon: Pencil }]
|
||||
: []),
|
||||
...(canRename(item) ? [{ label: t("contextMenu.renameObject"), action: () => requestRename(item), icon: Pencil }] : []),
|
||||
{ label: "", separator: true },
|
||||
{
|
||||
label: item.type === "PROCEDURE" ? t("contextMenu.dropProcedure") : t("contextMenu.dropFunction"),
|
||||
|
|
@ -1346,19 +1213,11 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
|||
<div class="min-w-0 truncate text-sm font-medium">
|
||||
{{ props.database }}<template v-if="selectedSchema"> / {{ selectedSchema }}</template>
|
||||
</div>
|
||||
<div class="shrink-0 rounded border bg-muted/40 px-1.5 py-0.5 text-xs text-muted-foreground">
|
||||
{{ filteredRows.length }} / {{ rows.length }}
|
||||
</div>
|
||||
<div class="shrink-0 rounded border bg-muted/40 px-1.5 py-0.5 text-xs text-muted-foreground">{{ filteredRows.length }} / {{ rows.length }}</div>
|
||||
</div>
|
||||
<div class="flex min-w-[240px] flex-1 items-center gap-2">
|
||||
<Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<Input
|
||||
v-model="search"
|
||||
data-object-search-input
|
||||
class="h-7 text-xs"
|
||||
:placeholder="t('objects.search')"
|
||||
@keydown="onSearchKeydown"
|
||||
/>
|
||||
<Input v-model="search" data-object-search-input class="h-7 text-xs" :placeholder="t('objects.search')" @keydown="onSearchKeydown" />
|
||||
<div v-if="showObjectFilter" class="flex h-7 shrink-0 items-center rounded border bg-muted/20 p-0.5">
|
||||
<button
|
||||
v-for="filter in objectFilters"
|
||||
|
|
@ -1375,12 +1234,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Select
|
||||
v-if="needsSchema"
|
||||
:model-value="selectedSchema"
|
||||
:disabled="loadingSchemas"
|
||||
@update:model-value="onSchemaChange"
|
||||
>
|
||||
<Select v-if="needsSchema" :model-value="selectedSchema" :disabled="loadingSchemas" @update:model-value="onSchemaChange">
|
||||
<SelectTrigger class="h-7 w-36 text-xs">
|
||||
<SelectValue :placeholder="loadingSchemas ? t('objects.loadingSchemas') : t('objects.schema')" />
|
||||
</SelectTrigger>
|
||||
|
|
@ -1417,23 +1271,12 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
|||
<div v-else-if="error" class="flex flex-1 items-center justify-center px-6 text-center text-sm text-destructive">
|
||||
{{ error }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="filteredRows.length === 0"
|
||||
class="flex flex-1 items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
<div v-else-if="filteredRows.length === 0" class="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
{{ t("objects.empty") }}
|
||||
</div>
|
||||
<div v-else class="flex min-h-0 flex-1 flex-col">
|
||||
<div
|
||||
class="grid h-8 shrink-0 items-center gap-3 border-b bg-muted/40 px-3 text-xs font-medium text-muted-foreground"
|
||||
:style="{ gridTemplateColumns }"
|
||||
>
|
||||
<button
|
||||
class="flex h-6 w-6 items-center justify-center rounded-sm hover:bg-accent"
|
||||
type="button"
|
||||
:disabled="visibleSelectableRows.length === 0"
|
||||
@click="toggleVisibleTableSelection"
|
||||
>
|
||||
<div class="grid h-8 shrink-0 items-center gap-3 border-b bg-muted/40 px-3 text-xs font-medium text-muted-foreground" :style="{ gridTemplateColumns }">
|
||||
<button class="flex h-6 w-6 items-center justify-center rounded-sm hover:bg-accent" type="button" :disabled="visibleSelectableRows.length === 0" @click="toggleVisibleTableSelection">
|
||||
<CheckSquare v-if="allVisibleTablesSelected" class="h-3.5 w-3.5 text-primary" />
|
||||
<Square v-else class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
|
@ -1445,42 +1288,20 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
|||
<span class="truncate">{{ t("objects.type") }}</span>
|
||||
<component :is="sortIconFor('type')" v-if="sortIconFor('type')" class="h-3 w-3 shrink-0" />
|
||||
</button>
|
||||
<button
|
||||
v-if="hasCreatedAt"
|
||||
class="flex min-w-0 items-center gap-1 truncate text-left"
|
||||
type="button"
|
||||
@click="toggleSort('created_at')"
|
||||
>
|
||||
<button v-if="hasCreatedAt" class="flex min-w-0 items-center gap-1 truncate text-left" type="button" @click="toggleSort('created_at')">
|
||||
<span class="truncate">{{ t("objects.createdAt") }}</span>
|
||||
<component :is="sortIconFor('created_at')" v-if="sortIconFor('created_at')" class="h-3 w-3 shrink-0" />
|
||||
</button>
|
||||
<button
|
||||
v-if="hasUpdatedAt"
|
||||
class="flex min-w-0 items-center gap-1 truncate text-left"
|
||||
type="button"
|
||||
@click="toggleSort('updated_at')"
|
||||
>
|
||||
<button v-if="hasUpdatedAt" class="flex min-w-0 items-center gap-1 truncate text-left" type="button" @click="toggleSort('updated_at')">
|
||||
<span class="truncate">{{ t("objects.updatedAt") }}</span>
|
||||
<component :is="sortIconFor('updated_at')" v-if="sortIconFor('updated_at')" class="h-3 w-3 shrink-0" />
|
||||
</button>
|
||||
<button
|
||||
v-if="hasComments"
|
||||
class="flex min-w-0 items-center gap-1 truncate text-left"
|
||||
type="button"
|
||||
@click="toggleSort('comment')"
|
||||
>
|
||||
<button v-if="hasComments" class="flex min-w-0 items-center gap-1 truncate text-left" type="button" @click="toggleSort('comment')">
|
||||
<span class="truncate">{{ t("objects.comment") }}</span>
|
||||
<component :is="sortIconFor('comment')" v-if="sortIconFor('comment')" class="h-3 w-3 shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
<RecycleScroller
|
||||
class="object-browser-scroller min-h-0 flex-1"
|
||||
:items="filteredRows"
|
||||
:item-size="38"
|
||||
:buffer="600"
|
||||
:skip-hover="true"
|
||||
key-field="id"
|
||||
>
|
||||
<RecycleScroller class="object-browser-scroller min-h-0 flex-1" :items="filteredRows" :item-size="38" :buffer="600" :skip-hover="true" key-field="id">
|
||||
<template #default="{ item }">
|
||||
<CustomContextMenu :items="getObjectBrowserMenuItems(item)" v-slot="{ onContextMenu }">
|
||||
<div
|
||||
|
|
@ -1493,12 +1314,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
|||
@click="onRowClick(item, $event)"
|
||||
@contextmenu="onContextMenu"
|
||||
>
|
||||
<button
|
||||
class="flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
type="button"
|
||||
:class="{ invisible: item.type !== 'TABLE' }"
|
||||
@click.stop="toggleTableSelection(item)"
|
||||
>
|
||||
<button class="flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground" type="button" :class="{ invisible: item.type !== 'TABLE' }" @click.stop="toggleTableSelection(item)">
|
||||
<CheckSquare v-if="selectedTableIds.has(item.id)" class="h-3.5 w-3.5 text-primary" />
|
||||
<Square v-else class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
|
@ -1516,26 +1332,15 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
|||
<span v-else class="h-5 w-5 shrink-0" :class="{ 'ml-4': item.partitionParentId }" />
|
||||
<component :is="iconFor(item)" class="h-3.5 w-3.5 shrink-0" :class="iconClass(item.type)" />
|
||||
<span class="truncate text-[13px] font-medium text-foreground">{{ item.name }}</span>
|
||||
<span
|
||||
v-if="item.partitionCount"
|
||||
class="shrink-0 rounded border bg-muted/40 px-1.5 py-0.5 text-[10px] font-medium leading-none text-muted-foreground"
|
||||
>
|
||||
<span v-if="item.partitionCount" class="shrink-0 rounded border bg-muted/40 px-1.5 py-0.5 text-[10px] font-medium leading-none text-muted-foreground">
|
||||
{{ t("objects.partitions", { count: item.partitionCount }) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="truncate text-xs text-muted-foreground">{{ typeLabel(item.type) }}</div>
|
||||
<div
|
||||
v-if="hasCreatedAt"
|
||||
class="truncate text-xs tabular-nums text-muted-foreground"
|
||||
:title="formatObjectBrowserTimestamp(item.created_at)"
|
||||
>
|
||||
<div v-if="hasCreatedAt" class="truncate text-xs tabular-nums text-muted-foreground" :title="formatObjectBrowserTimestamp(item.created_at)">
|
||||
{{ formatObjectBrowserTimestamp(item.created_at) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="hasUpdatedAt"
|
||||
class="truncate text-xs tabular-nums text-muted-foreground"
|
||||
:title="formatObjectBrowserTimestamp(item.updated_at)"
|
||||
>
|
||||
<div v-if="hasUpdatedAt" class="truncate text-xs tabular-nums text-muted-foreground" :title="formatObjectBrowserTimestamp(item.updated_at)">
|
||||
{{ formatObjectBrowserTimestamp(item.updated_at) }}
|
||||
</div>
|
||||
<div v-if="hasComments" class="truncate text-xs text-muted-foreground" :title="item.comment || ''">
|
||||
|
|
@ -1549,45 +1354,17 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
|||
<div class="flex h-8 shrink-0 items-center gap-2 border-b bg-muted/20 px-3">
|
||||
<Code2 class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate text-xs font-medium">{{ sourceTitle(sourceRow) }}</span>
|
||||
<Button
|
||||
v-if="sourceEditing"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 px-2 text-xs"
|
||||
:disabled="sourceSaving || !sourceDraft.trim()"
|
||||
@click="saveSource"
|
||||
>
|
||||
<Button v-if="sourceEditing" variant="ghost" size="sm" class="h-6 px-2 text-xs" :disabled="sourceSaving || !sourceDraft.trim()" @click="saveSource">
|
||||
<Loader2 v-if="sourceSaving" class="mr-1 h-3 w-3 animate-spin" />
|
||||
{{ t("objects.saveSource") }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="sourceEditing"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 px-2 text-xs"
|
||||
:disabled="sourceSaving"
|
||||
@click="cancelEditSource"
|
||||
>
|
||||
<Button v-if="sourceEditing" variant="ghost" size="sm" class="h-6 px-2 text-xs" :disabled="sourceSaving" @click="cancelEditSource">
|
||||
{{ t("objects.cancelEdit") }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!sourceEditing"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5"
|
||||
:disabled="!sourceContent"
|
||||
@click="copySource"
|
||||
>
|
||||
<Button v-if="!sourceEditing" variant="ghost" size="icon" class="h-5 w-5" :disabled="!sourceContent" @click="copySource">
|
||||
<Copy class="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!sourceEditing"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5"
|
||||
:disabled="!sourceContent"
|
||||
@click="editSource"
|
||||
>
|
||||
<Button v-if="!sourceEditing" variant="ghost" size="icon" class="h-5 w-5" :disabled="!sourceContent" @click="editSource">
|
||||
<PencilLine class="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="closeSource">
|
||||
|
|
@ -1601,18 +1378,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
|||
{{ sourceError }}
|
||||
</div>
|
||||
<div v-else-if="sourceEditing" class="flex min-h-0 flex-1 flex-col" data-object-source-editor>
|
||||
<QueryEditor
|
||||
v-model="sourceDraft"
|
||||
class="min-h-0 flex-1"
|
||||
:connection-id="props.connection.id"
|
||||
:database="props.database"
|
||||
:schema="selectedSchema"
|
||||
:database-type="props.connection.db_type"
|
||||
:dialect="sourceDialect"
|
||||
:format-dialect="sourceFormatDialect"
|
||||
force-word-wrap
|
||||
@save="saveSource"
|
||||
/>
|
||||
<QueryEditor v-model="sourceDraft" class="min-h-0 flex-1" :connection-id="props.connection.id" :database="props.database" :schema="selectedSchema" :database-type="props.connection.db_type" :dialect="sourceDialect" :format-dialect="sourceFormatDialect" force-word-wrap @save="saveSource" />
|
||||
<div v-if="sourceSaveError" class="shrink-0 border-t px-3 py-2 text-xs text-destructive">
|
||||
{{ sourceSaveError }}
|
||||
</div>
|
||||
|
|
@ -1636,22 +1402,9 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<DangerConfirmDialog
|
||||
v-model:open="showDropConfirm"
|
||||
:title="dropConfirmTitle()"
|
||||
:details="dropConfirmMessage()"
|
||||
:confirm-label="t('dangerDialog.deleteConfirm')"
|
||||
@confirm="confirmDrop"
|
||||
/>
|
||||
<DangerConfirmDialog v-model:open="showDropConfirm" :title="dropConfirmTitle()" :details="dropConfirmMessage()" :confirm-label="t('dangerDialog.deleteConfirm')" @confirm="confirmDrop" />
|
||||
|
||||
<DangerConfirmDialog
|
||||
v-model:open="showBatchDropConfirm"
|
||||
:title="t('objects.confirmBatchDropTitle')"
|
||||
:message="t('objects.confirmBatchDropMessage', { count: selectedTableCount })"
|
||||
:sql="batchDropPreviewSql"
|
||||
:confirm-label="t('objects.dropSelected')"
|
||||
@confirm="confirmBatchDropTables"
|
||||
/>
|
||||
<DangerConfirmDialog v-model:open="showBatchDropConfirm" :title="t('objects.confirmBatchDropTitle')" :message="t('objects.confirmBatchDropMessage', { count: selectedTableCount })" :sql="batchDropPreviewSql" :confirm-label="t('objects.dropSelected')" @confirm="confirmBatchDropTables" />
|
||||
|
||||
<Dialog v-model:open="showRenameDialog">
|
||||
<DialogContent class="sm:max-w-[420px]">
|
||||
|
|
@ -1659,16 +1412,8 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
|||
<DialogTitle>{{ t("contextMenu.renameObjectTitle") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="grid gap-3">
|
||||
<Input
|
||||
v-model="renameInput"
|
||||
:placeholder="t('contextMenu.renameObjectNamePlaceholder')"
|
||||
@keydown.enter.prevent="confirmRename"
|
||||
/>
|
||||
<pre
|
||||
v-if="renamePreviewSqlText"
|
||||
class="max-h-32 overflow-auto rounded bg-muted p-3 text-xs whitespace-pre-wrap"
|
||||
v-html="highlight(renamePreviewSqlText)"
|
||||
></pre>
|
||||
<Input v-model="renameInput" :placeholder="t('contextMenu.renameObjectNamePlaceholder')" @keydown.enter.prevent="confirmRename" />
|
||||
<pre v-if="renamePreviewSqlText" class="max-h-32 overflow-auto rounded bg-muted p-3 text-xs whitespace-pre-wrap" v-html="highlight(renamePreviewSqlText)"></pre>
|
||||
<p v-if="renameError" class="text-sm text-destructive">{{ renameError }}</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
|
|
@ -1689,14 +1434,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
|||
@confirm="confirmTruncateTable"
|
||||
/>
|
||||
|
||||
<DangerConfirmDialog
|
||||
v-model:open="showEmptyConfirm"
|
||||
:title="t('contextMenu.confirmEmptyTableTitle')"
|
||||
:message="t('contextMenu.confirmEmptyTableMessage', { name: emptyTarget?.name ?? '' })"
|
||||
:sql="emptyPreviewSql"
|
||||
:confirm-label="t('contextMenu.emptyTable')"
|
||||
@confirm="confirmEmptyTable"
|
||||
/>
|
||||
<DangerConfirmDialog v-model:open="showEmptyConfirm" :title="t('contextMenu.confirmEmptyTableTitle')" :message="t('contextMenu.confirmEmptyTableMessage', { name: emptyTarget?.name ?? '' })" :sql="emptyPreviewSql" :confirm-label="t('contextMenu.emptyTable')" @confirm="confirmEmptyTable" />
|
||||
|
||||
<ProcedureExecutionDialog
|
||||
v-if="procedureExecutionTarget"
|
||||
|
|
@ -1715,11 +1453,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
|||
<DialogHeader>
|
||||
<DialogTitle>{{ t("contextMenu.duplicateNameTitle") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
v-model="duplicateTableName"
|
||||
:placeholder="t('contextMenu.duplicateNamePlaceholder')"
|
||||
@keydown.enter.prevent="confirmDuplicateStructure"
|
||||
/>
|
||||
<Input v-model="duplicateTableName" :placeholder="t('contextMenu.duplicateNamePlaceholder')" @keydown.enter.prevent="confirmDuplicateStructure" />
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="showDuplicateDialog = false">{{ t("dangerDialog.cancel") }}</Button>
|
||||
<Button :disabled="!duplicateTableName.trim()" @click="confirmDuplicateStructure">
|
||||
|
|
|
|||
|
|
@ -8,12 +8,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
|
|||
import { Input } from "@/components/ui/input";
|
||||
import LightTooltip from "@/components/ui/LightTooltip.vue";
|
||||
import { loadRoutineParameters } from "@/lib/routineParameters";
|
||||
import {
|
||||
acceptsRoutineInput,
|
||||
buildProcedureExecutionSql,
|
||||
buildProcedureExecutionSqlFromValues,
|
||||
type RoutineParameterValue,
|
||||
} from "@/lib/routineExecutionSql";
|
||||
import { acceptsRoutineInput, buildProcedureExecutionSql, buildProcedureExecutionSqlFromValues, type RoutineParameterValue } from "@/lib/routineExecutionSql";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
|
@ -143,9 +138,7 @@ function canEditParameter(parameter: RoutineParameterValue): boolean {
|
|||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent
|
||||
class="max-h-[86vh] border border-border !bg-background text-foreground shadow-2xl !backdrop-blur-none sm:max-w-[780px]"
|
||||
>
|
||||
<DialogContent class="max-h-[86vh] border border-border !bg-background text-foreground shadow-2xl !backdrop-blur-none sm:max-w-[780px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("contextMenu.confirmExecuteProcedureTitle") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
|
@ -170,19 +163,14 @@ function canEditParameter(parameter: RoutineParameterValue): boolean {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loading"
|
||||
class="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<div v-if="loading" class="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2 text-sm text-muted-foreground">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
{{ t("contextMenu.loadingProcedureParameters") }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="parameters.length" class="overflow-x-auto rounded-md border bg-background">
|
||||
<div class="min-w-[650px]">
|
||||
<div
|
||||
class="grid grid-cols-[minmax(120px,1.2fr)_minmax(96px,1fr)_72px_minmax(160px,1.5fr)_64px_86px] border-b bg-muted px-3 py-2 text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
<div class="grid grid-cols-[minmax(120px,1.2fr)_minmax(96px,1fr)_72px_minmax(160px,1.5fr)_64px_86px] border-b bg-muted px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<div>{{ t("contextMenu.parameterName") }}</div>
|
||||
<div>{{ t("contextMenu.parameterType") }}</div>
|
||||
<div>{{ t("contextMenu.parameterMode") }}</div>
|
||||
|
|
@ -191,54 +179,24 @@ function canEditParameter(parameter: RoutineParameterValue): boolean {
|
|||
<div class="flex items-center gap-1">
|
||||
{{ t("contextMenu.parameterDefault") }}
|
||||
<LightTooltip :text="t('contextMenu.parameterDefaultHint')" side="top" :delay="150">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-4 w-4 items-center justify-center rounded-full text-muted-foreground hover:bg-background hover:text-foreground"
|
||||
:aria-label="t('contextMenu.parameterDefaultHint')"
|
||||
>
|
||||
<button type="button" class="inline-flex h-4 w-4 items-center justify-center rounded-full text-muted-foreground hover:bg-background hover:text-foreground" :aria-label="t('contextMenu.parameterDefaultHint')">
|
||||
<CircleHelp class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</LightTooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="parameter in parameters"
|
||||
:key="`${parameter.ordinal}:${parameter.name}`"
|
||||
class="grid grid-cols-[minmax(120px,1.2fr)_minmax(96px,1fr)_72px_minmax(160px,1.5fr)_64px_86px] items-center gap-2 border-b px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
<div v-for="parameter in parameters" :key="`${parameter.ordinal}:${parameter.name}`" class="grid grid-cols-[minmax(120px,1.2fr)_minmax(96px,1fr)_72px_minmax(160px,1.5fr)_64px_86px] items-center gap-2 border-b px-3 py-2 text-sm last:border-b-0">
|
||||
<div class="min-w-0 truncate font-medium">{{ parameter.name }}</div>
|
||||
<div class="min-w-0 truncate text-muted-foreground">{{ parameter.dataType || "-" }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ parameter.mode }}</div>
|
||||
<Input
|
||||
v-model="parameter.value"
|
||||
class="h-8 bg-background font-mono text-xs"
|
||||
:disabled="!canEditParameter(parameter) || parameter.useNull || parameter.useDefault"
|
||||
:placeholder="
|
||||
canEditParameter(parameter) ? t('contextMenu.parameterValuePlaceholder') : t('contextMenu.outputOnly')
|
||||
"
|
||||
/>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 accent-primary"
|
||||
:checked="!!parameter.useNull"
|
||||
:disabled="!canEditParameter(parameter) || parameter.useDefault"
|
||||
@change="(event: Event) => (parameter.useNull = (event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 accent-primary"
|
||||
:checked="!!parameter.useDefault"
|
||||
:disabled="!canEditParameter(parameter) || !parameter.hasDefault || parameter.useNull"
|
||||
@change="(event: Event) => (parameter.useDefault = (event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<Input v-model="parameter.value" class="h-8 bg-background font-mono text-xs" :disabled="!canEditParameter(parameter) || parameter.useNull || parameter.useDefault" :placeholder="canEditParameter(parameter) ? t('contextMenu.parameterValuePlaceholder') : t('contextMenu.outputOnly')" />
|
||||
<input type="checkbox" class="h-4 w-4 accent-primary" :checked="!!parameter.useNull" :disabled="!canEditParameter(parameter) || parameter.useDefault" @change="(event: Event) => (parameter.useNull = (event.target as HTMLInputElement).checked)" />
|
||||
<input type="checkbox" class="h-4 w-4 accent-primary" :checked="!!parameter.useDefault" :disabled="!canEditParameter(parameter) || !parameter.hasDefault || parameter.useNull" @change="(event: Event) => (parameter.useDefault = (event.target as HTMLInputElement).checked)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-else-if="loadError"
|
||||
class="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800"
|
||||
>
|
||||
<p v-else-if="loadError" class="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
||||
{{ t("contextMenu.procedureParametersUnavailable") }}
|
||||
</p>
|
||||
|
||||
|
|
@ -253,12 +211,7 @@ function canEditParameter(parameter: RoutineParameterValue): boolean {
|
|||
{{ t("contextMenu.resetSqlPreview") }}
|
||||
</Button>
|
||||
</div>
|
||||
<textarea
|
||||
:value="sqlDraft"
|
||||
class="min-h-28 w-full resize-y rounded-md border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/40"
|
||||
spellcheck="false"
|
||||
@input="onSqlInput"
|
||||
></textarea>
|
||||
<textarea :value="sqlDraft" class="min-h-28 w-full resize-y rounded-md border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/40" spellcheck="false" @input="onSqlInput"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -128,12 +128,7 @@ function renderJsonNode(node: JsonNode): VNodeChild {
|
|||
}
|
||||
|
||||
if (node.parentKind !== "root") {
|
||||
rowChildren.push(
|
||||
node.parentKind === "array"
|
||||
? h("span", { class: "redis-json-index" }, `[${node.label}]`)
|
||||
: highlightedJsonSpan("redis-json-key", JSON.stringify(node.label)),
|
||||
h("span", { class: "redis-json-colon" }, ":"),
|
||||
);
|
||||
rowChildren.push(node.parentKind === "array" ? h("span", { class: "redis-json-index" }, `[${node.label}]`) : highlightedJsonSpan("redis-json-key", JSON.stringify(node.label)), h("span", { class: "redis-json-colon" }, ":"));
|
||||
}
|
||||
|
||||
if (container) {
|
||||
|
|
@ -146,10 +141,7 @@ function renderJsonNode(node: JsonNode): VNodeChild {
|
|||
rowChildren.push(highlightedJsonSpan(scalarClass(node.value), scalarText(node.value)));
|
||||
}
|
||||
|
||||
return h("div", { class: "redis-json-node" }, [
|
||||
h("div", { class: "redis-json-row", style: { paddingLeft: indent } }, rowChildren),
|
||||
container && !collapsed ? h("div", { class: "redis-json-children" }, children.map(renderJsonNode)) : null,
|
||||
]);
|
||||
return h("div", { class: "redis-json-node" }, [h("div", { class: "redis-json-row", style: { paddingLeft: indent } }, rowChildren), container && !collapsed ? h("div", { class: "redis-json-children" }, children.map(renderJsonNode)) : null]);
|
||||
}
|
||||
|
||||
const JsonTreeNode = defineComponent({
|
||||
|
|
|
|||
|
|
@ -1,21 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, onMounted, onUnmounted, onActivated, onDeactivated, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
Search,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
FolderClosed,
|
||||
FolderOpen,
|
||||
Trash2,
|
||||
Plus,
|
||||
KeyRound,
|
||||
TerminalSquare,
|
||||
Asterisk,
|
||||
History,
|
||||
} from "@lucide/vue";
|
||||
import { Search, RefreshCw, Loader2, ChevronRight, ChevronDown, FolderClosed, FolderOpen, Trash2, Plus, KeyRound, TerminalSquare, Asterisk, History } from "@lucide/vue";
|
||||
import { RecycleScroller } from "vue-virtual-scroller";
|
||||
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
|
|
@ -34,14 +20,7 @@ import type { RedisKeyInfo, RedisScanResult, HistoryEntry } from "@/lib/api";
|
|||
import { uuid } from "@/lib/utils";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import {
|
||||
buildRedisKeyTree,
|
||||
collectExpandedGroupIds,
|
||||
collectRedisGroupKeyRaws,
|
||||
flattenVisibleRedisKeyTree,
|
||||
mergeKeysIntoRedisKeyTree,
|
||||
type RedisKeyTreeNode,
|
||||
} from "@/lib/redisKeyTree";
|
||||
import { buildRedisKeyTree, collectExpandedGroupIds, collectRedisGroupKeyRaws, flattenVisibleRedisKeyTree, mergeKeysIntoRedisKeyTree, type RedisKeyTreeNode } from "@/lib/redisKeyTree";
|
||||
import { classifyRedisCommandSafety } from "@/lib/redisCommandSafety";
|
||||
import { isRedisClearScreenCommand, nextRedisCommandDb, redisKeyTextToRaw } from "@/lib/redisCommandSession";
|
||||
import { formatRedisCommandResult, formatRedisStringValue } from "@/lib/redisValuePresentation";
|
||||
|
|
@ -94,9 +73,7 @@ const hasMore = ref(false);
|
|||
const scanCursor = ref(0);
|
||||
const expandedGroupIds = ref<Set<string>>(new Set());
|
||||
const checkedKeys = ref<Set<string>>(new Set());
|
||||
const pendingDanger = ref<
|
||||
{ kind: "delete-keys"; title: string; keyRaws: string[] } | { kind: "command"; command: string } | null
|
||||
>(null);
|
||||
const pendingDanger = ref<{ kind: "delete-keys"; title: string; keyRaws: string[] } | { kind: "command"; command: string } | null>(null);
|
||||
const showDangerConfirm = ref(false);
|
||||
const commandText = ref("");
|
||||
const commandRunning = ref(false);
|
||||
|
|
@ -123,22 +100,10 @@ let redisBrowserIsActive = true;
|
|||
let redisDbFlushedListenerRegistered = false;
|
||||
|
||||
const valueQuery = computed(() => searchPattern.value.trim());
|
||||
const effectivePattern = computed(() =>
|
||||
searchMode.value === "key" ? redisKeySearchPattern(searchPattern.value, fuzzyKeySearch.value) : "*",
|
||||
);
|
||||
const isSearchMode = computed(() =>
|
||||
searchMode.value === "key" ? effectivePattern.value !== "*" : valueQuery.value !== "",
|
||||
);
|
||||
const searchPlaceholder = computed(() =>
|
||||
searchMode.value === "key"
|
||||
? fuzzyKeySearch.value
|
||||
? t("redis.fuzzyPattern")
|
||||
: t("redis.pattern")
|
||||
: t("redis.valueSearchPlaceholder"),
|
||||
);
|
||||
const loadingEmptyText = computed(() =>
|
||||
searchMode.value === "value" && valueQuery.value ? t("redis.searchingValues") : t("redis.loadingKeys"),
|
||||
);
|
||||
const effectivePattern = computed(() => (searchMode.value === "key" ? redisKeySearchPattern(searchPattern.value, fuzzyKeySearch.value) : "*"));
|
||||
const isSearchMode = computed(() => (searchMode.value === "key" ? effectivePattern.value !== "*" : valueQuery.value !== ""));
|
||||
const searchPlaceholder = computed(() => (searchMode.value === "key" ? (fuzzyKeySearch.value ? t("redis.fuzzyPattern") : t("redis.pattern")) : t("redis.valueSearchPlaceholder")));
|
||||
const loadingEmptyText = computed(() => (searchMode.value === "value" && valueQuery.value ? t("redis.searchingValues") : t("redis.loadingKeys")));
|
||||
const lastTotalKeys = ref(0);
|
||||
const fetchAllProgressText = computed(() => {
|
||||
if (!isFetchingAll.value) return "";
|
||||
|
|
@ -223,9 +188,7 @@ function mergeTree(newKeys: RedisKeyInfo[]) {
|
|||
|
||||
async function fetchScanPage(): Promise<RedisScanResult> {
|
||||
const pageSize = settingsStore.editorSettings.redisScanPageSize;
|
||||
return searchMode.value === "value"
|
||||
? await api.redisScanValues(props.connectionId, props.db, scanCursor.value, "*", valueQuery.value, pageSize)
|
||||
: await api.redisScanKeys(props.connectionId, props.db, scanCursor.value, effectivePattern.value, pageSize);
|
||||
return searchMode.value === "value" ? await api.redisScanValues(props.connectionId, props.db, scanCursor.value, "*", valueQuery.value, pageSize) : await api.redisScanKeys(props.connectionId, props.db, scanCursor.value, effectivePattern.value, pageSize);
|
||||
}
|
||||
|
||||
function appendScanResult(result: RedisScanResult) {
|
||||
|
|
@ -265,12 +228,7 @@ async function streamValueSearch(requestId: number) {
|
|||
async function fillInitialKeyBatch(requestId: number) {
|
||||
const targetCount = Math.max(1, settingsStore.editorSettings.redisScanPageSize);
|
||||
let rounds = 0;
|
||||
while (
|
||||
requestId === searchRequestId &&
|
||||
searchMode.value === "key" &&
|
||||
hasMore.value &&
|
||||
flatKeys.value.length < targetCount
|
||||
) {
|
||||
while (requestId === searchRequestId && searchMode.value === "key" && hasMore.value && flatKeys.value.length < targetCount) {
|
||||
const beforeCount = flatKeys.value.length;
|
||||
const applied = await scanNextPage(requestId);
|
||||
if (!applied) return;
|
||||
|
|
@ -650,9 +608,7 @@ async function createRedisKey() {
|
|||
}
|
||||
}
|
||||
} else if (createKeyType.value === "stream") {
|
||||
const fields: [string, string][] = createKeyEntries.value
|
||||
.filter((e) => e.field && e.field.trim())
|
||||
.map((e) => [e.field!.trim(), e.value]);
|
||||
const fields: [string, string][] = createKeyEntries.value.filter((e) => e.field && e.field.trim()).map((e) => [e.field!.trim(), e.value]);
|
||||
if (fields.length > 0) {
|
||||
const entryId = createKeyEntryId.value.trim() || "*";
|
||||
await api.redisStreamAdd(props.connectionId, props.db, keyRaw, entryId, fields, ttl);
|
||||
|
|
@ -883,49 +839,15 @@ defineExpose({ focusSearch });
|
|||
<div class="h-9 flex items-center gap-1 px-2 border-b shrink-0">
|
||||
<Search class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<div class="h-6 flex rounded-md border bg-muted/30 p-0.5 shrink-0" role="group">
|
||||
<button
|
||||
type="button"
|
||||
class="h-5 px-2 text-xs rounded-sm transition-colors"
|
||||
:class="
|
||||
searchMode === 'key'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
"
|
||||
@click="setSearchMode('key')"
|
||||
>
|
||||
<button type="button" class="h-5 px-2 text-xs rounded-sm transition-colors" :class="searchMode === 'key' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" @click="setSearchMode('key')">
|
||||
{{ t("redis.searchByKey") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-5 px-2 text-xs rounded-sm transition-colors"
|
||||
:class="
|
||||
searchMode === 'value'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
"
|
||||
@click="setSearchMode('value')"
|
||||
>
|
||||
<button type="button" class="h-5 px-2 text-xs rounded-sm transition-colors" :class="searchMode === 'value' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" @click="setSearchMode('value')">
|
||||
{{ t("redis.searchByValue") }}
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
v-model="searchPattern"
|
||||
data-redis-search-input
|
||||
class="h-6 text-xs border-0 shadow-none focus-visible:ring-0"
|
||||
:placeholder="searchPlaceholder"
|
||||
@input="onSearchInput"
|
||||
@keydown="onSearchKeydown"
|
||||
/>
|
||||
<Button
|
||||
v-if="searchMode === 'key'"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 shrink-0 px-2 text-xs"
|
||||
:class="fuzzyKeySearch ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'"
|
||||
:title="t('redis.fuzzyMatchTitle')"
|
||||
:aria-pressed="fuzzyKeySearch"
|
||||
@click="toggleFuzzyKeySearch"
|
||||
>
|
||||
<Input v-model="searchPattern" data-redis-search-input class="h-6 text-xs border-0 shadow-none focus-visible:ring-0" :placeholder="searchPlaceholder" @input="onSearchInput" @keydown="onSearchKeydown" />
|
||||
<Button v-if="searchMode === 'key'" variant="ghost" size="sm" class="h-6 shrink-0 px-2 text-xs" :class="fuzzyKeySearch ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'" :title="t('redis.fuzzyMatchTitle')" :aria-pressed="fuzzyKeySearch" @click="toggleFuzzyKeySearch">
|
||||
<Asterisk class="h-3 w-3 mr-1" />
|
||||
{{ t("redis.fuzzyMatch") }}
|
||||
</Button>
|
||||
|
|
@ -933,108 +855,42 @@ defineExpose({ focusSearch });
|
|||
<Loader2 v-if="loading" class="h-3 w-3 animate-spin" />
|
||||
<RefreshCw v-else class="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6 shrink-0"
|
||||
:title="t('redis.createKey')"
|
||||
@click="openCreateKeyDialog"
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" :title="t('redis.createKey')" @click="openCreateKeyDialog">
|
||||
<Plus class="h-3 w-3" />
|
||||
</Button>
|
||||
<span class="text-xs text-muted-foreground shrink-0 ml-1">{{
|
||||
loading && flatKeys.length === 0 ? loadingEmptyText : t("redis.keys", { count: flatKeys.length })
|
||||
}}</span>
|
||||
<Button
|
||||
v-if="checkedKeys.size > 0"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 text-xs text-destructive shrink-0 ml-1"
|
||||
@click="requestBatchDelete"
|
||||
>
|
||||
<Trash2 class="w-3 h-3 mr-1" />{{ checkedKeys.size }}
|
||||
</Button>
|
||||
<span class="text-xs text-muted-foreground shrink-0 ml-1">{{ loading && flatKeys.length === 0 ? loadingEmptyText : t("redis.keys", { count: flatKeys.length }) }}</span>
|
||||
<Button v-if="checkedKeys.size > 0" variant="ghost" size="sm" class="h-6 text-xs text-destructive shrink-0 ml-1" @click="requestBatchDelete"> <Trash2 class="w-3 h-3 mr-1" />{{ checkedKeys.size }} </Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="flatKeys.length === 0 && !loading"
|
||||
class="flex-1 flex items-center justify-center text-muted-foreground text-xs"
|
||||
>
|
||||
<div v-if="flatKeys.length === 0 && !loading" class="flex-1 flex items-center justify-center text-muted-foreground text-xs">
|
||||
{{ t("redis.noKeys") }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="loading && flatKeys.length === 0"
|
||||
class="flex-1 flex items-center justify-center gap-2 text-muted-foreground text-xs"
|
||||
>
|
||||
<div v-else-if="loading && flatKeys.length === 0" class="flex-1 flex items-center justify-center gap-2 text-muted-foreground text-xs">
|
||||
<Loader2 class="w-3.5 h-3.5 animate-spin" />
|
||||
<span>{{ loadingEmptyText }}</span>
|
||||
</div>
|
||||
<RecycleScroller
|
||||
v-else
|
||||
class="redis-key-scroller flex-1"
|
||||
:items="visibleRows"
|
||||
:item-size="30"
|
||||
:buffer="600"
|
||||
:skip-hover="true"
|
||||
key-field="id"
|
||||
>
|
||||
<RecycleScroller v-else class="redis-key-scroller flex-1" :items="visibleRows" :item-size="30" :buffer="600" :skip-hover="true" key-field="id">
|
||||
<template #default="{ item: row }">
|
||||
<div
|
||||
class="flex items-center gap-2 border-b px-3 text-[13px] cursor-pointer hover:bg-accent/50 group"
|
||||
:class="{ 'bg-accent': row.node.kind === 'leaf' && selectedKeyRaw === row.node.keyRaw }"
|
||||
:style="{ height: '30px' }"
|
||||
@click="onRowClick(row.node)"
|
||||
>
|
||||
<div
|
||||
class="min-w-0 flex flex-1 items-center gap-1 overflow-hidden"
|
||||
:style="{ paddingLeft: `${12 + row.depth * 16}px` }"
|
||||
>
|
||||
<div class="flex items-center gap-2 border-b px-3 text-[13px] cursor-pointer hover:bg-accent/50 group" :class="{ 'bg-accent': row.node.kind === 'leaf' && selectedKeyRaw === row.node.keyRaw }" :style="{ height: '30px' }" @click="onRowClick(row.node)">
|
||||
<div class="min-w-0 flex flex-1 items-center gap-1 overflow-hidden" :style="{ paddingLeft: `${12 + row.depth * 16}px` }">
|
||||
<template v-if="row.node.kind === 'group'">
|
||||
<component
|
||||
:is="expandedGroupIds.has(row.node.id) ? ChevronDown : ChevronRight"
|
||||
class="w-3 h-3 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<component
|
||||
:is="expandedGroupIds.has(row.node.id) ? FolderOpen : FolderClosed"
|
||||
class="w-3 h-3 shrink-0 text-amber-500"
|
||||
/>
|
||||
<component :is="expandedGroupIds.has(row.node.id) ? ChevronDown : ChevronRight" class="w-3 h-3 shrink-0 text-muted-foreground" />
|
||||
<component :is="expandedGroupIds.has(row.node.id) ? FolderOpen : FolderClosed" class="w-3 h-3 shrink-0 text-amber-500" />
|
||||
<span class="dbx-editor-font-family truncate">{{ row.node.label }}</span>
|
||||
<span class="text-muted-foreground ml-1">({{ countLeaves(row.node) }})</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="relative flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<KeyRound
|
||||
class="h-3.5 w-3.5 text-muted-foreground/70 transition-opacity group-hover:opacity-0"
|
||||
:class="{ 'opacity-0': checkedKeys.has(row.node.keyRaw) }"
|
||||
/>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="absolute h-3.5 w-3.5 accent-primary cursor-pointer opacity-0 group-hover:opacity-100"
|
||||
:class="{ 'opacity-100': checkedKeys.has(row.node.keyRaw) }"
|
||||
:checked="checkedKeys.has(row.node.keyRaw)"
|
||||
@click="toggleCheck(row.node.keyRaw, $event)"
|
||||
/>
|
||||
<KeyRound class="h-3.5 w-3.5 text-muted-foreground/70 transition-opacity group-hover:opacity-0" :class="{ 'opacity-0': checkedKeys.has(row.node.keyRaw) }" />
|
||||
<input type="checkbox" class="absolute h-3.5 w-3.5 accent-primary cursor-pointer opacity-0 group-hover:opacity-100" :class="{ 'opacity-100': checkedKeys.has(row.node.keyRaw) }" :checked="checkedKeys.has(row.node.keyRaw)" @click="toggleCheck(row.node.keyRaw, $event)" />
|
||||
</span>
|
||||
<span class="dbx-editor-font-family truncate">{{ row.node.label }}</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center justify-end gap-1">
|
||||
<Badge
|
||||
v-if="row.node.kind === 'leaf'"
|
||||
variant="outline"
|
||||
class="text-xs px-1.5 py-0"
|
||||
:class="typeColor(row.node.keyType)"
|
||||
>{{ row.node.keyType }}</Badge
|
||||
>
|
||||
<Button
|
||||
v-if="row.node.kind === 'group'"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 shrink-0 text-destructive opacity-0 group-hover:opacity-100"
|
||||
:title="t('redis.deleteGroup')"
|
||||
@click="requestGroupDelete(row.node, $event)"
|
||||
>
|
||||
<Badge v-if="row.node.kind === 'leaf'" variant="outline" class="text-xs px-1.5 py-0" :class="typeColor(row.node.keyType)">{{ row.node.keyType }}</Badge>
|
||||
<Button v-if="row.node.kind === 'group'" variant="ghost" size="icon" class="h-5 w-5 shrink-0 text-destructive opacity-0 group-hover:opacity-100" :title="t('redis.deleteGroup')" @click="requestGroupDelete(row.node, $event)">
|
||||
<Trash2 class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -1042,23 +898,11 @@ defineExpose({ focusSearch });
|
|||
</template>
|
||||
</RecycleScroller>
|
||||
<div v-if="hasMore && !isFetchingAll" class="shrink-0 border-t px-2 py-1.5 flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs flex-1"
|
||||
:disabled="loadingMore || loading"
|
||||
@click="loadMore"
|
||||
>
|
||||
<Button variant="outline" size="sm" class="h-7 text-xs flex-1" :disabled="loadingMore || loading" @click="loadMore">
|
||||
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
|
||||
{{ t("redis.loadMoreKeys") }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs flex-1"
|
||||
:disabled="loading || !hasMore"
|
||||
@click="fetchAll"
|
||||
>
|
||||
<Button variant="outline" size="sm" class="h-7 text-xs flex-1" :disabled="loading || !hasMore" @click="fetchAll">
|
||||
{{ t("redis.fetchAllKeys") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -1083,48 +927,25 @@ defineExpose({ focusSearch });
|
|||
<KeyRound class="size-3.5" />
|
||||
{{ t("redis.keyDetail") }}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="command"
|
||||
class="h-6 flex-none gap-1.5 rounded-md px-2 text-xs"
|
||||
@click="openCommandPanel"
|
||||
>
|
||||
<TabsTrigger value="command" class="h-6 flex-none gap-1.5 rounded-md px-2 text-xs" @click="openCommandPanel">
|
||||
<TerminalSquare class="size-3.5" />
|
||||
{{ t("redis.commandLine") }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<Button
|
||||
v-if="activeSidePanel === 'command'"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
:title="t('redis.clearHistory')"
|
||||
@click="clearPersistedRedisHistory"
|
||||
>
|
||||
<Button v-if="activeSidePanel === 'command'" variant="ghost" size="icon" class="h-6 w-6" :title="t('redis.clearHistory')" @click="clearPersistedRedisHistory">
|
||||
<History class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<TabsContent value="detail" class="m-0 min-h-0 flex-1 flex flex-col">
|
||||
<RedisValueViewer
|
||||
v-if="selectedKey"
|
||||
:key="selectedKey.key_raw"
|
||||
:connection-id="connectionId"
|
||||
:db="db"
|
||||
:key-display="selectedKey.key_display"
|
||||
:key-raw="selectedKey.key_raw"
|
||||
:metadata="selectedKey"
|
||||
@deleted="onKeyDeleted"
|
||||
/>
|
||||
<RedisValueViewer v-if="selectedKey" :key="selectedKey.key_raw" :connection-id="connectionId" :db="db" :key-display="selectedKey.key_display" :key-raw="selectedKey.key_raw" :metadata="selectedKey" @deleted="onKeyDeleted" />
|
||||
<div v-else class="flex-1 flex items-center justify-center text-xs text-muted-foreground">
|
||||
{{ t("redis.selectKeyForDetail") }}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="command" class="m-0 min-h-0 flex-1 flex flex-col">
|
||||
<div
|
||||
class="dbx-editor-font-family relative flex min-h-0 flex-1 flex-col bg-[#171b21] text-[13px] leading-5 text-slate-200"
|
||||
@click="getCommandInput()?.focus()"
|
||||
>
|
||||
<div class="dbx-editor-font-family relative flex min-h-0 flex-1 flex-col bg-[#171b21] text-[13px] leading-5 text-slate-200" @click="getCommandInput()?.focus()">
|
||||
<div ref="commandTerminalRef" class="min-h-0 flex-1 overflow-auto px-4 pb-3 pt-4">
|
||||
<div class="mb-4 text-slate-400">
|
||||
<span class="text-slate-200">{{ t("redis.commandWelcome") }}</span>
|
||||
|
|
@ -1135,19 +956,11 @@ defineExpose({ focusSearch });
|
|||
<span class="shrink-0 text-[#d7ba7d]">{{ entry.prompt }}</span>
|
||||
<span class="min-w-0 text-slate-200">{{ entry.command }}</span>
|
||||
</div>
|
||||
<pre
|
||||
v-if="entry.output"
|
||||
class="ml-0 whitespace-pre-wrap break-words pl-0"
|
||||
:class="entry.error ? 'text-[#ff6b6b]' : 'text-slate-300'"
|
||||
>{{ entry.output }}</pre
|
||||
>
|
||||
<pre v-if="entry.output" class="ml-0 whitespace-pre-wrap break-words pl-0" :class="entry.error ? 'text-[#ff6b6b]' : 'text-slate-300'">{{ entry.output }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="flex shrink-0 items-center gap-2 border-t border-white/10 bg-[#171b21] px-4 py-2"
|
||||
@submit.prevent="executeCommand"
|
||||
>
|
||||
<form class="flex shrink-0 items-center gap-2 border-t border-white/10 bg-[#171b21] px-4 py-2" @submit.prevent="executeCommand">
|
||||
<span class="shrink-0 text-[#d7ba7d]">{{ commandPrompt }}</span>
|
||||
<input
|
||||
v-model="commandText"
|
||||
|
|
@ -1168,13 +981,7 @@ defineExpose({ focusSearch });
|
|||
</Pane>
|
||||
</Splitpanes>
|
||||
|
||||
<DangerConfirmDialog
|
||||
v-model:open="showDangerConfirm"
|
||||
:message="t('dangerDialog.deleteMessage')"
|
||||
:details="dangerDetails"
|
||||
:confirm-label="dangerConfirmLabel"
|
||||
@confirm="applyDangerAction"
|
||||
/>
|
||||
<DangerConfirmDialog v-model:open="showDangerConfirm" :message="t('dangerDialog.deleteMessage')" :details="dangerDetails" :confirm-label="dangerConfirmLabel" @confirm="applyDangerAction" />
|
||||
|
||||
<Dialog v-model:open="showCreateKeyDialog">
|
||||
<DialogContent class="sm:max-w-md" :style="editorFontFamilyStyle">
|
||||
|
|
@ -1185,12 +992,7 @@ defineExpose({ focusSearch });
|
|||
<div class="grid gap-3">
|
||||
<label class="grid gap-1.5 text-xs font-medium">
|
||||
<span>{{ t("redis.createKeyName") }}</span>
|
||||
<Input
|
||||
v-model="createKeyName"
|
||||
class="dbx-editor-font-family h-8 text-xs"
|
||||
:placeholder="t('redis.createKeyNamePlaceholder')"
|
||||
@keydown.enter="createRedisKey"
|
||||
/>
|
||||
<Input v-model="createKeyName" class="dbx-editor-font-family h-8 text-xs" :placeholder="t('redis.createKeyNamePlaceholder')" @keydown.enter="createRedisKey" />
|
||||
</label>
|
||||
|
||||
<label class="grid gap-1.5 text-xs font-medium">
|
||||
|
|
@ -1209,42 +1011,22 @@ defineExpose({ focusSearch });
|
|||
|
||||
<label v-if="createKeyType === 'hash' && createKeyRawMode" class="grid gap-1.5 text-xs font-medium">
|
||||
<span>{{ t("redis.createField") }}</span>
|
||||
<Input
|
||||
v-model="createKeyField"
|
||||
class="dbx-editor-font-family h-8 text-xs"
|
||||
:placeholder="t('redis.createFieldPlaceholder')"
|
||||
@keydown.enter="createRedisKey"
|
||||
/>
|
||||
<Input v-model="createKeyField" class="dbx-editor-font-family h-8 text-xs" :placeholder="t('redis.createFieldPlaceholder')" @keydown.enter="createRedisKey" />
|
||||
</label>
|
||||
|
||||
<label v-if="createKeyType === 'zset' && createKeyRawMode" class="grid gap-1.5 text-xs font-medium">
|
||||
<span>{{ t("redis.createScore") }}</span>
|
||||
<Input
|
||||
v-model="createKeyScore"
|
||||
class="dbx-editor-font-family h-8 text-xs"
|
||||
placeholder="0"
|
||||
@keydown.enter="createRedisKey"
|
||||
/>
|
||||
<Input v-model="createKeyScore" class="dbx-editor-font-family h-8 text-xs" placeholder="0" @keydown.enter="createRedisKey" />
|
||||
</label>
|
||||
|
||||
<!-- TTL input -- always visible -->
|
||||
<label class="grid gap-1.5 text-xs font-medium">
|
||||
<span>{{ t("redis.createKeyTtl") }}</span>
|
||||
<Input
|
||||
v-model="createKeyTtl"
|
||||
class="dbx-editor-font-family h-8 text-xs"
|
||||
type="number"
|
||||
min="0"
|
||||
:placeholder="t('redis.createKeyTtlPlaceholder')"
|
||||
@keydown.enter="createRedisKey"
|
||||
/>
|
||||
<Input v-model="createKeyTtl" class="dbx-editor-font-family h-8 text-xs" type="number" min="0" :placeholder="t('redis.createKeyTtlPlaceholder')" @keydown.enter="createRedisKey" />
|
||||
</label>
|
||||
|
||||
<!-- Raw mode toggle (non-string, non-stream, non-json types) -->
|
||||
<div
|
||||
v-if="createKeyType !== 'string' && createKeyType !== 'stream' && createKeyType !== 'json'"
|
||||
class="flex items-center justify-end gap-1.5"
|
||||
>
|
||||
<div v-if="createKeyType !== 'string' && createKeyType !== 'stream' && createKeyType !== 'json'" class="flex items-center justify-end gap-1.5">
|
||||
<label class="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span>{{ t("redis.createKeyRawMode") }}</span>
|
||||
<Switch size="sm" v-model="createKeyRawMode" />
|
||||
|
|
@ -1270,47 +1052,19 @@ defineExpose({ focusSearch });
|
|||
<div v-for="(entry, idx) in createKeyEntries" :key="entry.id" class="flex items-start gap-2">
|
||||
<!-- Hash / Stream: field + value -->
|
||||
<template v-if="createKeyType === 'hash' || createKeyType === 'stream'">
|
||||
<Input
|
||||
v-model="entry.field"
|
||||
class="dbx-editor-font-family h-8 w-2/5 text-xs"
|
||||
:placeholder="t('redis.createFieldPlaceholder')"
|
||||
/>
|
||||
<Input
|
||||
v-model="entry.value"
|
||||
class="dbx-editor-font-family h-8 flex-1 text-xs"
|
||||
:placeholder="t('redis.createValuePlaceholder')"
|
||||
/>
|
||||
<Input v-model="entry.field" class="dbx-editor-font-family h-8 w-2/5 text-xs" :placeholder="t('redis.createFieldPlaceholder')" />
|
||||
<Input v-model="entry.value" class="dbx-editor-font-family h-8 flex-1 text-xs" :placeholder="t('redis.createValuePlaceholder')" />
|
||||
</template>
|
||||
<!-- ZSet: score + member -->
|
||||
<template v-else-if="createKeyType === 'zset'">
|
||||
<Input
|
||||
v-model="entry.score"
|
||||
class="dbx-editor-font-family h-8 w-20 text-xs"
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="0"
|
||||
/>
|
||||
<Input
|
||||
v-model="entry.value"
|
||||
class="dbx-editor-font-family h-8 flex-1 text-xs"
|
||||
:placeholder="t('redis.createMember')"
|
||||
/>
|
||||
<Input v-model="entry.score" class="dbx-editor-font-family h-8 w-20 text-xs" type="number" step="any" placeholder="0" />
|
||||
<Input v-model="entry.value" class="dbx-editor-font-family h-8 flex-1 text-xs" :placeholder="t('redis.createMember')" />
|
||||
</template>
|
||||
<!-- List / Set: single value -->
|
||||
<template v-else>
|
||||
<Input
|
||||
v-model="entry.value"
|
||||
class="dbx-editor-font-family h-8 flex-1 text-xs"
|
||||
:placeholder="t('redis.createValuePlaceholder')"
|
||||
/>
|
||||
<Input v-model="entry.value" class="dbx-editor-font-family h-8 flex-1 text-xs" :placeholder="t('redis.createValuePlaceholder')" />
|
||||
</template>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 w-8 shrink-0 p-0 text-muted-foreground hover:text-destructive"
|
||||
:disabled="createKeyEntries.length <= 1"
|
||||
@click="removeEntry(idx)"
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="h-8 w-8 shrink-0 p-0 text-muted-foreground hover:text-destructive" :disabled="createKeyEntries.length <= 1" @click="removeEntry(idx)">
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -1318,19 +1072,9 @@ defineExpose({ focusSearch });
|
|||
</template>
|
||||
|
||||
<!-- Raw value textarea (string, json, or raw mode for other types) -->
|
||||
<label
|
||||
v-if="createKeyType === 'string' || createKeyType === 'json' || createKeyRawMode"
|
||||
class="grid gap-1.5 text-xs font-medium"
|
||||
>
|
||||
<span>{{
|
||||
t(createKeyType === "set" || createKeyType === "zset" ? "redis.createMember" : "redis.createValue")
|
||||
}}</span>
|
||||
<textarea
|
||||
v-model="createKeyValue"
|
||||
class="dbx-editor-font-family min-h-28 resize-y rounded-md border bg-background p-2 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
spellcheck="false"
|
||||
:placeholder="t('redis.createValuePlaceholder')"
|
||||
/>
|
||||
<label v-if="createKeyType === 'string' || createKeyType === 'json' || createKeyRawMode" class="grid gap-1.5 text-xs font-medium">
|
||||
<span>{{ t(createKeyType === "set" || createKeyType === "zset" ? "redis.createMember" : "redis.createValue") }}</span>
|
||||
<textarea v-model="createKeyValue" class="dbx-editor-font-family min-h-28 resize-y rounded-md border bg-background p-2 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring" spellcheck="false" :placeholder="t('redis.createValuePlaceholder')" />
|
||||
</label>
|
||||
|
||||
<p v-if="createKeyError" class="text-xs text-destructive">{{ createKeyError }}</p>
|
||||
|
|
@ -1340,10 +1084,7 @@ defineExpose({ focusSearch });
|
|||
<Button variant="ghost" :disabled="creatingKey" @click="showCreateKeyDialog = false">
|
||||
{{ t("dangerDialog.cancel") }}
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="creatingKey || checkingJsonModule || (createKeyType === 'json' && jsonModuleAvailable !== true)"
|
||||
@click="createRedisKey"
|
||||
>
|
||||
<Button :disabled="creatingKey || checkingJsonModule || (createKeyType === 'json' && jsonModuleAvailable !== true)" @click="createRedisKey">
|
||||
<Loader2 v-if="creatingKey" class="h-4 w-4 animate-spin" />
|
||||
<Plus v-else class="h-4 w-4" />
|
||||
{{ t("redis.createKeySubmit") }}
|
||||
|
|
|
|||
|
|
@ -2,22 +2,7 @@
|
|||
import { computed, ref, onBeforeUnmount, onMounted } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { DynamicScroller, DynamicScrollerItem, RecycleScroller } from "vue-virtual-scroller";
|
||||
import {
|
||||
Braces,
|
||||
Copy,
|
||||
Eye,
|
||||
FileText,
|
||||
Terminal,
|
||||
Trash2,
|
||||
Save,
|
||||
RefreshCw,
|
||||
Plus,
|
||||
Loader2,
|
||||
Pencil,
|
||||
WrapText,
|
||||
IndentIncrease,
|
||||
IndentDecrease,
|
||||
} from "@lucide/vue";
|
||||
import { Braces, Copy, Eye, FileText, Terminal, Trash2, Save, RefreshCw, Plus, Loader2, Pencil, WrapText, IndentIncrease, IndentDecrease } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -32,12 +17,7 @@ import { useTheme } from "@/composables/useTheme";
|
|||
import { useEditorFontFamilyStyle } from "@/composables/useEditorFontFamilyStyle";
|
||||
import { createRedisShikiJsonHighlighter, type RedisJsonHighlighter } from "@/lib/redisJsonHighlighter";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import {
|
||||
canEditRedisMemberDetail,
|
||||
clampRedisMemberDetailSheetWidth,
|
||||
formatRedisMemberDetail,
|
||||
getRedisMemberSelectionKey,
|
||||
} from "@/lib/redisValuePresentation";
|
||||
import { canEditRedisMemberDetail, clampRedisMemberDetailSheetWidth, formatRedisMemberDetail, getRedisMemberSelectionKey } from "@/lib/redisValuePresentation";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
|
|
@ -91,32 +71,21 @@ const redisJsonWordWrap = ref(readRedisJsonWordWrap());
|
|||
const redisJsonHighlighter = ref<RedisJsonHighlighter>();
|
||||
const selectedMemberDetail = computed(() => formatRedisMemberDetail(selectedMemberRaw.value));
|
||||
const selectedMemberJsonDetail = computed(() => selectedMemberDetail.value.json ?? null);
|
||||
const stringValueDetail = computed(() =>
|
||||
data.value?.key_type === "string" ? formatRedisMemberDetail(data.value.value) : null,
|
||||
);
|
||||
const stringValueDetail = computed(() => (data.value?.key_type === "string" ? formatRedisMemberDetail(data.value.value) : null));
|
||||
const stringJsonDetail = computed(() => stringValueDetail.value?.json ?? null);
|
||||
const redisJsonAppearance = computed(() => (isDark.value ? "dark" : "light"));
|
||||
const memberRawJsonHtml = computed(() =>
|
||||
selectedMemberJsonDetail.value ? highlightRedisJson(selectedMemberJsonDetail.value.rawText) : "",
|
||||
);
|
||||
const memberRawJsonHtml = computed(() => (selectedMemberJsonDetail.value ? highlightRedisJson(selectedMemberJsonDetail.value.rawText) : ""));
|
||||
const hashGridStyle = computed(() => ({
|
||||
gridTemplateColumns: `${hashFieldWidth.value}px minmax(12rem, 1fr) 84px`,
|
||||
}));
|
||||
const zsetGridStyle = computed(() => ({
|
||||
gridTemplateColumns: `${zsetScoreWidth.value}px minmax(0, 1fr) 84px`,
|
||||
}));
|
||||
const selectedMemberCanEdit = computed(
|
||||
() => selectedMemberContext.value != null && canEditRedisMemberDetail(selectedMemberContext.value.kind),
|
||||
);
|
||||
const selectedMemberCanEdit = computed(() => selectedMemberContext.value != null && canEditRedisMemberDetail(selectedMemberContext.value.kind));
|
||||
const REDIS_COLLECTION_ROW_HEIGHT = 32;
|
||||
const REDIS_STREAM_MIN_ROW_HEIGHT = 96;
|
||||
|
||||
type PendingDelete =
|
||||
| { kind: "key" }
|
||||
| { kind: "hash"; field: string }
|
||||
| { kind: "list"; index: number }
|
||||
| { kind: "set"; member: string }
|
||||
| { kind: "zset"; member: string };
|
||||
type PendingDelete = { kind: "key" } | { kind: "hash"; field: string } | { kind: "list"; index: number } | { kind: "set"; member: string } | { kind: "zset"; member: string };
|
||||
|
||||
const pendingDelete = ref<PendingDelete | null>(null);
|
||||
|
||||
|
|
@ -127,12 +96,7 @@ let hashResizeStartWidth = 0;
|
|||
let zsetResizeStartX = 0;
|
||||
let zsetResizeStartWidth = 0;
|
||||
|
||||
type RedisMemberContext =
|
||||
| { kind: "list"; index: number }
|
||||
| { kind: "set"; member: string }
|
||||
| { kind: "hash"; field: string }
|
||||
| { kind: "zset"; member: string; score: number }
|
||||
| { kind: "stream"; field: string };
|
||||
type RedisMemberContext = { kind: "list"; index: number } | { kind: "set"; member: string } | { kind: "hash"; field: string } | { kind: "zset"; member: string; score: number } | { kind: "stream"; field: string };
|
||||
|
||||
type RedisCollectionRow = {
|
||||
id: string;
|
||||
|
|
@ -208,12 +172,9 @@ const deleteDetails = computed(() => {
|
|||
const pending = pendingDelete.value;
|
||||
if (!pending) return "";
|
||||
if (pending.kind === "key") return t("dangerDialog.redisKeyDetails", { key: props.keyDisplay });
|
||||
if (pending.kind === "hash")
|
||||
return t("dangerDialog.redisHashFieldDetails", { key: props.keyDisplay, field: pending.field });
|
||||
if (pending.kind === "list")
|
||||
return t("dangerDialog.redisListItemDetails", { key: props.keyDisplay, index: pending.index });
|
||||
if (pending.kind === "zset")
|
||||
return t("dangerDialog.redisSetMemberDetails", { key: props.keyDisplay, member: pending.member });
|
||||
if (pending.kind === "hash") return t("dangerDialog.redisHashFieldDetails", { key: props.keyDisplay, field: pending.field });
|
||||
if (pending.kind === "list") return t("dangerDialog.redisListItemDetails", { key: props.keyDisplay, index: pending.index });
|
||||
if (pending.kind === "zset") return t("dangerDialog.redisSetMemberDetails", { key: props.keyDisplay, member: pending.member });
|
||||
return t("dangerDialog.redisSetMemberDetails", { key: props.keyDisplay, member: pending.member });
|
||||
});
|
||||
|
||||
|
|
@ -262,14 +223,7 @@ async function loadMore() {
|
|||
if (!data.value || !hasMore.value || loadingMore.value) return;
|
||||
loadingMore.value = true;
|
||||
try {
|
||||
const result = await api.redisLoadMore(
|
||||
props.connectionId,
|
||||
props.db,
|
||||
props.keyRaw,
|
||||
data.value.key_type,
|
||||
scanCursor.value!,
|
||||
200,
|
||||
);
|
||||
const result = await api.redisLoadMore(props.connectionId, props.db, props.keyRaw, data.value.key_type, scanCursor.value!, 200);
|
||||
const newItems = Array.isArray(result.value) ? result.value : [];
|
||||
collectionItems.value = [...collectionItems.value, ...newItems];
|
||||
scanCursor.value = result.scan_cursor ?? undefined;
|
||||
|
|
@ -420,9 +374,7 @@ function generateInsertStatements(): string | null {
|
|||
break;
|
||||
}
|
||||
case "hash": {
|
||||
const pairs = collectionItems.value
|
||||
.map((v) => `${escapeRedisArg(String(v.field))} ${escapeRedisArg(String(v.value))}`)
|
||||
.join(" ");
|
||||
const pairs = collectionItems.value.map((v) => `${escapeRedisArg(String(v.field))} ${escapeRedisArg(String(v.value))}`).join(" ");
|
||||
commands.push(`HSET ${escapeRedisArg(key)} ${pairs}`);
|
||||
break;
|
||||
}
|
||||
|
|
@ -518,10 +470,7 @@ function stopResizeMemberSheet() {
|
|||
function resizeMemberSheet(event: PointerEvent) {
|
||||
if (!isResizingMemberSheet.value) return;
|
||||
const delta = memberSheetResizeStartX - event.clientX;
|
||||
memberDetailSheetWidth.value = clampRedisMemberDetailSheetWidth(
|
||||
memberSheetResizeStartWidth + delta,
|
||||
window.innerWidth,
|
||||
);
|
||||
memberDetailSheetWidth.value = clampRedisMemberDetailSheetWidth(memberSheetResizeStartWidth + delta, window.innerWidth);
|
||||
}
|
||||
|
||||
function startResizeMemberSheet(event: PointerEvent) {
|
||||
|
|
@ -631,13 +580,7 @@ function selectDefaultMember(redisValue: RedisValue) {
|
|||
clearSelectedMember();
|
||||
return;
|
||||
}
|
||||
selectMember(
|
||||
redisValue.key_type === "list" ? "#0" : t("redis.member"),
|
||||
collectionItems.value[0],
|
||||
redisValue.key_type === "list"
|
||||
? { kind: "list", index: 0 }
|
||||
: { kind: "set", member: String(collectionItems.value[0]) },
|
||||
);
|
||||
selectMember(redisValue.key_type === "list" ? "#0" : t("redis.member"), collectionItems.value[0], redisValue.key_type === "list" ? { kind: "list", index: 0 } : { kind: "set", member: String(collectionItems.value[0]) });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -826,58 +769,22 @@ onBeforeUnmount(() => {
|
|||
<!-- Header -->
|
||||
<div class="shrink-0 border-b bg-background">
|
||||
<div class="flex h-9 items-center gap-2 px-4">
|
||||
<span class="dbx-editor-font-family min-w-0 flex-1 truncate text-sm font-semibold">{{
|
||||
data.key_display
|
||||
}}</span>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" @click="load"
|
||||
><RefreshCw class="h-3.5 w-3.5"
|
||||
/></Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" @click="copyValue"
|
||||
><Copy class="h-3.5 w-3.5"
|
||||
/></Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 shrink-0"
|
||||
:title="t('redis.copyInsertStatement')"
|
||||
@click="copyInsertStatement"
|
||||
><Terminal class="h-3.5 w-3.5"
|
||||
/></Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0 text-destructive" @click="requestDeleteKey"
|
||||
><Trash2 class="h-3.5 w-3.5"
|
||||
/></Button>
|
||||
<span class="dbx-editor-font-family min-w-0 flex-1 truncate text-sm font-semibold">{{ data.key_display }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" @click="load"><RefreshCw class="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" @click="copyValue"><Copy class="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" :title="t('redis.copyInsertStatement')" @click="copyInsertStatement"><Terminal class="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0 text-destructive" @click="requestDeleteKey"><Trash2 class="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-7 flex-wrap items-center gap-2 px-4 pb-1">
|
||||
<Badge variant="secondary" class="dbx-editor-font-family text-xs uppercase">{{ data.key_type }}</Badge>
|
||||
<Badge v-if="metadataSizeLabel" variant="outline" class="text-xs text-muted-foreground">
|
||||
{{ t("redis.columnSize") }}: {{ metadataSizeLabel }}
|
||||
</Badge>
|
||||
<Badge v-if="metadataSizeLabel" variant="outline" class="text-xs text-muted-foreground"> {{ t("redis.columnSize") }}: {{ metadataSizeLabel }} </Badge>
|
||||
<template v-if="!editingTtl">
|
||||
<Badge
|
||||
v-if="data.ttl > 0"
|
||||
variant="outline"
|
||||
class="text-xs cursor-pointer text-muted-foreground hover:bg-accent"
|
||||
@click="startEditTtl"
|
||||
>TTL: {{ data.ttl }}s</Badge
|
||||
>
|
||||
<Badge
|
||||
v-else-if="data.ttl === -1"
|
||||
variant="outline"
|
||||
class="text-xs cursor-pointer text-muted-foreground hover:bg-accent"
|
||||
@click="startEditTtl"
|
||||
>{{ t("redis.noExpiry") }}</Badge
|
||||
>
|
||||
<Badge v-if="data.ttl > 0" variant="outline" class="text-xs cursor-pointer text-muted-foreground hover:bg-accent" @click="startEditTtl">TTL: {{ data.ttl }}s</Badge>
|
||||
<Badge v-else-if="data.ttl === -1" variant="outline" class="text-xs cursor-pointer text-muted-foreground hover:bg-accent" @click="startEditTtl">{{ t("redis.noExpiry") }}</Badge>
|
||||
</template>
|
||||
<div v-else class="flex items-center gap-1">
|
||||
<Input
|
||||
v-model="ttlInput"
|
||||
class="h-6 w-20 text-xs"
|
||||
placeholder="seconds (-1=no expiry)"
|
||||
autofocus
|
||||
@keydown.enter="saveTtl"
|
||||
@keydown.escape="cancelEditTtl"
|
||||
/>
|
||||
<Input v-model="ttlInput" class="h-6 w-20 text-xs" placeholder="seconds (-1=no expiry)" autofocus @keydown.enter="saveTtl" @keydown.escape="cancelEditTtl" />
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6" @click="saveTtl"><Save class="h-3 w-3" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -887,76 +794,32 @@ onBeforeUnmount(() => {
|
|||
<div v-if="data.key_type === 'string'" class="flex-1 flex flex-col overflow-hidden">
|
||||
<div v-if="stringJsonDetail" class="flex h-9 items-center gap-2 border-b px-4 text-xs shrink-0">
|
||||
<div class="flex overflow-hidden rounded-md border bg-muted/20 p-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 rounded-[5px] px-2 text-xs"
|
||||
:class="{ 'bg-background shadow-sm': stringValueView === 'json' }"
|
||||
@click="stringValueView = 'json'"
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="h-6 rounded-[5px] px-2 text-xs" :class="{ 'bg-background shadow-sm': stringValueView === 'json' }" @click="stringValueView = 'json'">
|
||||
<Braces class="h-3.5 w-3.5" />
|
||||
{{ t("redis.jsonView") }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 rounded-[5px] px-2 text-xs"
|
||||
:class="{ 'bg-background shadow-sm': stringValueView === 'raw' }"
|
||||
@click="stringValueView = 'raw'"
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="h-6 rounded-[5px] px-2 text-xs" :class="{ 'bg-background shadow-sm': stringValueView === 'raw' }" @click="stringValueView = 'raw'">
|
||||
<FileText class="h-3.5 w-3.5" />
|
||||
{{ t("redis.rawContent") }}
|
||||
</Button>
|
||||
</div>
|
||||
<span class="flex-1" />
|
||||
<Button
|
||||
v-if="stringValueView === 'raw'"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 rounded-[5px] px-2 text-xs"
|
||||
:title="t('redis.formatJson')"
|
||||
@click="handleFormatStringJson"
|
||||
>
|
||||
<Button v-if="stringValueView === 'raw'" variant="ghost" size="sm" class="h-6 rounded-[5px] px-2 text-xs" :title="t('redis.formatJson')" @click="handleFormatStringJson">
|
||||
<IndentIncrease class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="stringValueView === 'raw'"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 rounded-[5px] px-2 text-xs"
|
||||
:title="t('redis.compressJson')"
|
||||
@click="handleCompressStringJson"
|
||||
>
|
||||
<Button v-if="stringValueView === 'raw'" variant="ghost" size="sm" class="h-6 rounded-[5px] px-2 text-xs" :title="t('redis.compressJson')" @click="handleCompressStringJson">
|
||||
<IndentDecrease class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<label class="flex items-center gap-1.5 text-muted-foreground">
|
||||
<WrapText class="h-3.5 w-3.5" />
|
||||
{{ t("redis.wordWrap") }}
|
||||
<Switch
|
||||
size="sm"
|
||||
:model-value="redisJsonWordWrap"
|
||||
@update:model-value="setRedisJsonWordWrap(Boolean($event))"
|
||||
/>
|
||||
<Switch size="sm" :model-value="redisJsonWordWrap" @update:model-value="setRedisJsonWordWrap(Boolean($event))" />
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
v-if="stringJsonDetail && stringValueView === 'json'"
|
||||
class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background p-4 text-sm leading-6"
|
||||
>
|
||||
<RedisJsonTree
|
||||
:value="stringJsonDetail.value"
|
||||
:word-wrap="redisJsonWordWrap"
|
||||
:highlight-json="highlightRedisJson"
|
||||
/>
|
||||
<div v-if="stringJsonDetail && stringValueView === 'json'" class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background p-4 text-sm leading-6">
|
||||
<RedisJsonTree :value="stringJsonDetail.value" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
|
||||
</div>
|
||||
<textarea
|
||||
v-else
|
||||
v-model="editValue"
|
||||
class="dbx-editor-font-family flex-1 p-4 text-sm bg-background resize-none outline-none"
|
||||
:class="{ 'whitespace-pre': stringJsonDetail && !redisJsonWordWrap }"
|
||||
:readonly="isBinaryStringValue"
|
||||
@input="handleStringInput"
|
||||
/>
|
||||
<textarea v-else v-model="editValue" class="dbx-editor-font-family flex-1 p-4 text-sm bg-background resize-none outline-none" :class="{ 'whitespace-pre': stringJsonDetail && !redisJsonWordWrap }" :readonly="isBinaryStringValue" @input="handleStringInput" />
|
||||
<div v-if="isBinaryStringValue" class="px-4 py-2 border-t text-xs text-muted-foreground shrink-0">
|
||||
{{ t("redis.binaryStringReadonlyHint") }}
|
||||
</div>
|
||||
|
|
@ -977,28 +840,17 @@ onBeforeUnmount(() => {
|
|||
<!-- List -->
|
||||
<div v-else-if="data.key_type === 'list'" class="flex-1 flex flex-col overflow-hidden">
|
||||
<div class="flex items-center gap-2 px-4 py-1.5 border-b shrink-0">
|
||||
<span class="text-xs text-muted-foreground">{{
|
||||
collectionCountLabel("items", collectionItems.length, data.total)
|
||||
}}</span>
|
||||
<span class="text-xs text-muted-foreground">{{ collectionCountLabel("items", collectionItems.length, data.total) }}</span>
|
||||
<span class="flex-1" />
|
||||
<Input v-model="newValue" class="h-6 w-40 text-xs" placeholder="value" @keydown.enter="listPush" />
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="listPush"
|
||||
><Plus class="w-3 h-3 mr-1" />Push</Button
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="listPush"><Plus class="w-3 h-3 mr-1" />Push</Button>
|
||||
</div>
|
||||
<div class="grid grid-cols-[60px_1fr_84px] border-b bg-muted/50 shrink-0">
|
||||
<div class="px-3 py-1 text-xs font-medium text-muted-foreground border-r">#</div>
|
||||
<div class="px-3 py-1 text-xs font-medium text-muted-foreground">Value</div>
|
||||
<div />
|
||||
</div>
|
||||
<RecycleScroller
|
||||
class="flex-1 overflow-y-auto"
|
||||
:items="collectionRows"
|
||||
:item-size="REDIS_COLLECTION_ROW_HEIGHT"
|
||||
:buffer="600"
|
||||
:skip-hover="true"
|
||||
key-field="id"
|
||||
>
|
||||
<RecycleScroller class="flex-1 overflow-y-auto" :items="collectionRows" :item-size="REDIS_COLLECTION_ROW_HEIGHT" :buffer="600" :skip-hover="true" key-field="id">
|
||||
<template #default="{ item: row }">
|
||||
<div
|
||||
data-redis-value-row
|
||||
|
|
@ -1010,29 +862,9 @@ onBeforeUnmount(() => {
|
|||
<div class="px-3 py-1.5 text-xs text-muted-foreground border-r">{{ row.index }}</div>
|
||||
<div class="px-3 py-1.5 truncate">{{ row.value }}</div>
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100"
|
||||
:title="t('redis.viewMember')"
|
||||
@click.stop="viewMember(`#${row.index}`, row.value, { kind: 'list', index: row.index })"
|
||||
><Eye class="w-3 h-3"
|
||||
/></Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100"
|
||||
:title="t('redis.copyMember')"
|
||||
@click.stop="copyMember(row.value)"
|
||||
><Copy class="w-3 h-3"
|
||||
/></Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive"
|
||||
@click.stop="requestListRemove(row.index)"
|
||||
><Trash2 class="w-3 h-3"
|
||||
/></Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100" :title="t('redis.viewMember')" @click.stop="viewMember(`#${row.index}`, row.value, { kind: 'list', index: row.index })"><Eye class="w-3 h-3" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100" :title="t('redis.copyMember')" @click.stop="copyMember(row.value)"><Copy class="w-3 h-3" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive" @click.stop="requestListRemove(row.index)"><Trash2 class="w-3 h-3" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1050,27 +882,16 @@ onBeforeUnmount(() => {
|
|||
<!-- Set -->
|
||||
<div v-else-if="data.key_type === 'set'" class="flex-1 flex flex-col overflow-hidden">
|
||||
<div class="flex items-center gap-2 px-4 py-1.5 border-b shrink-0">
|
||||
<span class="text-xs text-muted-foreground">{{
|
||||
collectionCountLabel("items", collectionItems.length, data.total)
|
||||
}}</span>
|
||||
<span class="text-xs text-muted-foreground">{{ collectionCountLabel("items", collectionItems.length, data.total) }}</span>
|
||||
<span class="flex-1" />
|
||||
<Input v-model="newValue" class="h-6 w-40 text-xs" placeholder="member" @keydown.enter="setAdd" />
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="setAdd"
|
||||
><Plus class="w-3 h-3 mr-1" />Add</Button
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="setAdd"><Plus class="w-3 h-3 mr-1" />Add</Button>
|
||||
</div>
|
||||
<div class="grid grid-cols-[1fr_84px] border-b bg-muted/50 shrink-0">
|
||||
<div class="px-3 py-1 text-xs font-medium text-muted-foreground">Member</div>
|
||||
<div />
|
||||
</div>
|
||||
<RecycleScroller
|
||||
class="flex-1 overflow-y-auto"
|
||||
:items="collectionRows"
|
||||
:item-size="REDIS_COLLECTION_ROW_HEIGHT"
|
||||
:buffer="600"
|
||||
:skip-hover="true"
|
||||
key-field="id"
|
||||
>
|
||||
<RecycleScroller class="flex-1 overflow-y-auto" :items="collectionRows" :item-size="REDIS_COLLECTION_ROW_HEIGHT" :buffer="600" :skip-hover="true" key-field="id">
|
||||
<template #default="{ item: row }">
|
||||
<div
|
||||
data-redis-value-row
|
||||
|
|
@ -1081,29 +902,9 @@ onBeforeUnmount(() => {
|
|||
>
|
||||
<div class="px-3 py-1.5 truncate">{{ row.value }}</div>
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100"
|
||||
:title="t('redis.viewMember')"
|
||||
@click.stop="viewMember(t('redis.member'), row.value, { kind: 'set', member: String(row.value) })"
|
||||
><Eye class="w-3 h-3"
|
||||
/></Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100"
|
||||
:title="t('redis.copyMember')"
|
||||
@click.stop="copyMember(row.value)"
|
||||
><Copy class="w-3 h-3"
|
||||
/></Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive"
|
||||
@click.stop="requestSetRemove(String(row.value))"
|
||||
><Trash2 class="w-3 h-3"
|
||||
/></Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100" :title="t('redis.viewMember')" @click.stop="viewMember(t('redis.member'), row.value, { kind: 'set', member: String(row.value) })"><Eye class="w-3 h-3" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100" :title="t('redis.copyMember')" @click.stop="copyMember(row.value)"><Copy class="w-3 h-3" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive" @click.stop="requestSetRemove(String(row.value))"><Trash2 class="w-3 h-3" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1121,44 +922,28 @@ onBeforeUnmount(() => {
|
|||
<!-- Hash -->
|
||||
<div v-else-if="data.key_type === 'hash'" ref="hashTableRef" class="flex-1 flex flex-col overflow-hidden">
|
||||
<div class="flex items-center gap-2 px-4 py-1.5 border-b shrink-0">
|
||||
<span class="text-xs text-muted-foreground">{{
|
||||
collectionCountLabel("fields", collectionItems.length, data.total)
|
||||
}}</span>
|
||||
<span class="text-xs text-muted-foreground">{{ collectionCountLabel("fields", collectionItems.length, data.total) }}</span>
|
||||
<span class="flex-1" />
|
||||
<Input v-model="newField" class="h-6 w-24 text-xs" placeholder="field" />
|
||||
<Input v-model="newValue" class="h-6 w-32 text-xs" placeholder="value" @keydown.enter="hashSet" />
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="hashSet"
|
||||
><Plus class="w-3 h-3 mr-1" />Set</Button
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="hashSet"><Plus class="w-3 h-3 mr-1" />Set</Button>
|
||||
</div>
|
||||
<div class="grid border-b bg-muted/50 shrink-0" :style="hashGridStyle">
|
||||
<div class="relative px-3 py-1 text-xs font-medium text-muted-foreground border-r select-none">
|
||||
Field
|
||||
<div
|
||||
class="absolute -right-1 top-0 h-full w-2 cursor-col-resize touch-none"
|
||||
@pointerdown.prevent="startResizeHashColumns"
|
||||
/>
|
||||
<div class="absolute -right-1 top-0 h-full w-2 cursor-col-resize touch-none" @pointerdown.prevent="startResizeHashColumns" />
|
||||
</div>
|
||||
<div class="px-3 py-1 text-xs font-medium text-muted-foreground">Value</div>
|
||||
<div />
|
||||
</div>
|
||||
<RecycleScroller
|
||||
class="flex-1 overflow-y-auto"
|
||||
:items="collectionRows"
|
||||
:item-size="REDIS_COLLECTION_ROW_HEIGHT"
|
||||
:buffer="600"
|
||||
:skip-hover="true"
|
||||
key-field="id"
|
||||
>
|
||||
<RecycleScroller class="flex-1 overflow-y-auto" :items="collectionRows" :item-size="REDIS_COLLECTION_ROW_HEIGHT" :buffer="600" :skip-hover="true" key-field="id">
|
||||
<template #default="{ item: row }">
|
||||
<div
|
||||
data-redis-value-row
|
||||
class="dbx-editor-font-family grid border-b text-sm hover:bg-accent/50 group cursor-pointer"
|
||||
:style="{ ...hashGridStyle, height: `${REDIS_COLLECTION_ROW_HEIGHT}px` }"
|
||||
:class="{ 'bg-accent/60': isSelectedMember(String(row.value.field), row.value.value) }"
|
||||
@click="
|
||||
viewMember(String(row.value.field), row.value.value, { kind: 'hash', field: String(row.value.field) })
|
||||
"
|
||||
@click="viewMember(String(row.value.field), row.value.value, { kind: 'hash', field: String(row.value.field) })"
|
||||
>
|
||||
<div class="px-3 py-1.5 text-blue-500 truncate border-r">{{ row.value.field }}</div>
|
||||
<div class="px-3 py-1.5 truncate text-muted-foreground">{{ row.value.value }}</div>
|
||||
|
|
@ -1176,21 +961,8 @@ onBeforeUnmount(() => {
|
|||
"
|
||||
><Eye class="w-3 h-3"
|
||||
/></Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100"
|
||||
:title="t('redis.copyMember')"
|
||||
@click.stop="copyMember(row.value.value)"
|
||||
><Copy class="w-3 h-3"
|
||||
/></Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive"
|
||||
@click.stop="requestHashDel(String(row.value.field))"
|
||||
><Trash2 class="w-3 h-3"
|
||||
/></Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100" :title="t('redis.copyMember')" @click.stop="copyMember(row.value.value)"><Copy class="w-3 h-3" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive" @click.stop="requestHashDel(String(row.value.field))"><Trash2 class="w-3 h-3" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1208,35 +980,21 @@ onBeforeUnmount(() => {
|
|||
<!-- Sorted Set -->
|
||||
<div v-else-if="data.key_type === 'zset'" ref="zsetTableRef" class="flex-1 flex flex-col overflow-hidden">
|
||||
<div class="flex items-center gap-2 px-4 py-1.5 border-b shrink-0">
|
||||
<span class="text-xs text-muted-foreground">{{
|
||||
collectionCountLabel("members", collectionItems.length, data.total)
|
||||
}}</span>
|
||||
<span class="text-xs text-muted-foreground">{{ collectionCountLabel("members", collectionItems.length, data.total) }}</span>
|
||||
<span class="flex-1" />
|
||||
<Input v-model="newScore" class="h-6 w-20 text-xs" placeholder="score" />
|
||||
<Input v-model="newValue" class="h-6 w-32 text-xs" placeholder="member" @keydown.enter="zsetAdd" />
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="zsetAdd"
|
||||
><Plus class="w-3 h-3 mr-1" />Add</Button
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="zsetAdd"><Plus class="w-3 h-3 mr-1" />Add</Button>
|
||||
</div>
|
||||
<div class="grid border-b bg-muted/50 shrink-0" :style="zsetGridStyle">
|
||||
<div class="relative px-3 py-1 text-xs font-medium text-muted-foreground border-r select-none">
|
||||
Score
|
||||
<div
|
||||
class="absolute -right-1 top-0 h-full w-2 cursor-col-resize touch-none"
|
||||
@pointerdown.prevent="startResizeZsetColumns"
|
||||
/>
|
||||
<div class="absolute -right-1 top-0 h-full w-2 cursor-col-resize touch-none" @pointerdown.prevent="startResizeZsetColumns" />
|
||||
</div>
|
||||
<div class="px-3 py-1 text-xs font-medium text-muted-foreground min-w-0">Member</div>
|
||||
<div />
|
||||
</div>
|
||||
<RecycleScroller
|
||||
class="flex-1 overflow-y-auto"
|
||||
:items="collectionRows"
|
||||
:item-size="REDIS_COLLECTION_ROW_HEIGHT"
|
||||
:buffer="600"
|
||||
:skip-hover="true"
|
||||
key-field="id"
|
||||
>
|
||||
<RecycleScroller class="flex-1 overflow-y-auto" :items="collectionRows" :item-size="REDIS_COLLECTION_ROW_HEIGHT" :buffer="600" :skip-hover="true" key-field="id">
|
||||
<template #default="{ item: row }">
|
||||
<div
|
||||
data-redis-value-row
|
||||
|
|
@ -1251,10 +1009,7 @@ onBeforeUnmount(() => {
|
|||
})
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="px-3 py-1.5 text-muted-foreground text-xs border-r min-w-0 truncate"
|
||||
:title="String(row.value.score)"
|
||||
>
|
||||
<div class="px-3 py-1.5 text-muted-foreground text-xs border-r min-w-0 truncate" :title="String(row.value.score)">
|
||||
{{ row.value.score }}
|
||||
</div>
|
||||
<div class="px-3 py-1.5 min-w-0 truncate" :title="String(row.value.member)">
|
||||
|
|
@ -1275,21 +1030,8 @@ onBeforeUnmount(() => {
|
|||
"
|
||||
><Eye class="w-3 h-3"
|
||||
/></Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100"
|
||||
:title="t('redis.copyMember')"
|
||||
@click.stop="copyMember(row.value.member)"
|
||||
><Copy class="w-3 h-3"
|
||||
/></Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive"
|
||||
@click.stop="requestZsetRemove(String(row.value.member))"
|
||||
><Trash2 class="w-3 h-3"
|
||||
/></Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100" :title="t('redis.copyMember')" @click.stop="copyMember(row.value.member)"><Copy class="w-3 h-3" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive" @click.stop="requestZsetRemove(String(row.value.member))"><Trash2 class="w-3 h-3" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1309,20 +1051,9 @@ onBeforeUnmount(() => {
|
|||
<div class="px-4 py-1 text-xs text-muted-foreground border-b shrink-0">
|
||||
{{ t("redis.entries", { count: streamRows.length }) }}
|
||||
</div>
|
||||
<DynamicScroller
|
||||
class="flex-1 overflow-y-auto"
|
||||
:items="streamRows"
|
||||
:min-item-size="REDIS_STREAM_MIN_ROW_HEIGHT"
|
||||
:buffer="600"
|
||||
key-field="id"
|
||||
>
|
||||
<DynamicScroller class="flex-1 overflow-y-auto" :items="streamRows" :min-item-size="REDIS_STREAM_MIN_ROW_HEIGHT" :buffer="600" key-field="id">
|
||||
<template #default="{ item: row, active }">
|
||||
<DynamicScrollerItem
|
||||
:item="row"
|
||||
:active="active"
|
||||
:size-dependencies="[streamFieldCount(row)]"
|
||||
:data-index="row.index"
|
||||
>
|
||||
<DynamicScrollerItem :item="row" :active="active" :size-dependencies="[streamFieldCount(row)]" :data-index="row.index">
|
||||
<div data-redis-stream-entry class="dbx-editor-font-family px-4 py-2 border-b text-sm hover:bg-accent/50">
|
||||
<div class="mb-1 text-xs text-muted-foreground">{{ row.entry.id }}</div>
|
||||
<div
|
||||
|
|
@ -1335,22 +1066,8 @@ onBeforeUnmount(() => {
|
|||
<span class="truncate text-blue-500">{{ field }}</span>
|
||||
<span class="truncate text-muted-foreground">{{ val }}</span>
|
||||
<span class="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100"
|
||||
:title="t('redis.viewMember')"
|
||||
@click.stop="viewMember(String(field), val, { kind: 'stream', field: String(field) })"
|
||||
><Eye class="w-3 h-3"
|
||||
/></Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100"
|
||||
:title="t('redis.copyMember')"
|
||||
@click.stop="copyMember(val)"
|
||||
><Copy class="w-3 h-3"
|
||||
/></Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100" :title="t('redis.viewMember')" @click.stop="viewMember(String(field), val, { kind: 'stream', field: String(field) })"><Eye class="w-3 h-3" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100" :title="t('redis.copyMember')" @click.stop="copyMember(val)"><Copy class="w-3 h-3" /></Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1365,13 +1082,7 @@ onBeforeUnmount(() => {
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<DangerConfirmDialog
|
||||
v-model:open="showDeleteConfirm"
|
||||
:message="t('dangerDialog.deleteMessage')"
|
||||
:details="deleteDetails"
|
||||
:confirm-label="t('dangerDialog.deleteConfirm')"
|
||||
@confirm="confirmDelete"
|
||||
/>
|
||||
<DangerConfirmDialog v-model:open="showDeleteConfirm" :message="t('dangerDialog.deleteMessage')" :details="deleteDetails" :confirm-label="t('dangerDialog.deleteConfirm')" @confirm="confirmDelete" />
|
||||
|
||||
<Sheet :open="showMemberDetail" @update:open="handleMemberDetailOpenChange">
|
||||
<SheetContent
|
||||
|
|
@ -1383,10 +1094,7 @@ onBeforeUnmount(() => {
|
|||
@pointer-down-outside.prevent
|
||||
@interact-outside.prevent
|
||||
>
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 z-10 w-2 -translate-x-1 cursor-col-resize border-l border-transparent hover:border-primary/60"
|
||||
@pointerdown.prevent="startResizeMemberSheet"
|
||||
/>
|
||||
<div class="absolute inset-y-0 left-0 z-10 w-2 -translate-x-1 cursor-col-resize border-l border-transparent hover:border-primary/60" @pointerdown.prevent="startResizeMemberSheet" />
|
||||
<SheetHeader class="border-b px-5 py-4 pr-12">
|
||||
<SheetTitle class="flex items-center gap-2">
|
||||
<span class="truncate">{{ selectedMemberTitle || t("redis.memberDetail") }}</span>
|
||||
|
|
@ -1396,108 +1104,46 @@ onBeforeUnmount(() => {
|
|||
<template v-if="isEditingMember">
|
||||
<div v-if="selectedMemberJsonDetail" class="flex h-9 items-center gap-2 border-b px-5 text-xs shrink-0">
|
||||
<span class="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 rounded-[5px] px-2 text-xs"
|
||||
:title="t('redis.formatJson')"
|
||||
@click="handleFormatMemberJson"
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="h-6 rounded-[5px] px-2 text-xs" :title="t('redis.formatJson')" @click="handleFormatMemberJson">
|
||||
<IndentIncrease class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 rounded-[5px] px-2 text-xs"
|
||||
:title="t('redis.compressJson')"
|
||||
@click="handleCompressMemberJson"
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="h-6 rounded-[5px] px-2 text-xs" :title="t('redis.compressJson')" @click="handleCompressMemberJson">
|
||||
<IndentDecrease class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="memberEditValue"
|
||||
class="dbx-editor-font-family min-h-0 flex-1 resize-none bg-background p-5 text-[13px] leading-6 outline-none"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<textarea v-model="memberEditValue" class="dbx-editor-font-family min-h-0 flex-1 resize-none bg-background p-5 text-[13px] leading-6 outline-none" spellcheck="false" />
|
||||
</template>
|
||||
<template v-else-if="selectedMemberJsonDetail">
|
||||
<div class="flex h-9 items-center gap-2 border-b px-5 text-xs">
|
||||
<div class="flex overflow-hidden rounded-md border bg-muted/20 p-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 rounded-[5px] px-2 text-xs"
|
||||
:class="{ 'bg-background shadow-sm': memberValueView === 'json' }"
|
||||
@click="memberValueView = 'json'"
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="h-6 rounded-[5px] px-2 text-xs" :class="{ 'bg-background shadow-sm': memberValueView === 'json' }" @click="memberValueView = 'json'">
|
||||
<Braces class="h-3.5 w-3.5" />
|
||||
{{ t("redis.jsonView") }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 rounded-[5px] px-2 text-xs"
|
||||
:class="{ 'bg-background shadow-sm': memberValueView === 'raw' }"
|
||||
@click="memberValueView = 'raw'"
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="h-6 rounded-[5px] px-2 text-xs" :class="{ 'bg-background shadow-sm': memberValueView === 'raw' }" @click="memberValueView = 'raw'">
|
||||
<FileText class="h-3.5 w-3.5" />
|
||||
{{ t("redis.rawContent") }}
|
||||
</Button>
|
||||
</div>
|
||||
<span class="flex-1" />
|
||||
<Button
|
||||
v-if="memberValueView === 'raw'"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 rounded-[5px] px-2 text-xs"
|
||||
:title="t('redis.formatJson')"
|
||||
@click="handleFormatMemberJson"
|
||||
>
|
||||
<Button v-if="memberValueView === 'raw'" variant="ghost" size="sm" class="h-6 rounded-[5px] px-2 text-xs" :title="t('redis.formatJson')" @click="handleFormatMemberJson">
|
||||
<IndentIncrease class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="memberValueView === 'raw'"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 rounded-[5px] px-2 text-xs"
|
||||
:title="t('redis.compressJson')"
|
||||
@click="handleCompressMemberJson"
|
||||
>
|
||||
<Button v-if="memberValueView === 'raw'" variant="ghost" size="sm" class="h-6 rounded-[5px] px-2 text-xs" :title="t('redis.compressJson')" @click="handleCompressMemberJson">
|
||||
<IndentDecrease class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<label class="flex items-center gap-1.5 text-muted-foreground">
|
||||
<WrapText class="h-3.5 w-3.5" />
|
||||
{{ t("redis.wordWrap") }}
|
||||
<Switch
|
||||
size="sm"
|
||||
:model-value="redisJsonWordWrap"
|
||||
@update:model-value="setRedisJsonWordWrap(Boolean($event))"
|
||||
/>
|
||||
<Switch size="sm" :model-value="redisJsonWordWrap" @update:model-value="setRedisJsonWordWrap(Boolean($event))" />
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
v-if="memberValueView === 'json'"
|
||||
class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background p-5 text-[13px] leading-6"
|
||||
>
|
||||
<RedisJsonTree
|
||||
:value="selectedMemberJsonDetail.value"
|
||||
:word-wrap="redisJsonWordWrap"
|
||||
:highlight-json="highlightRedisJson"
|
||||
/>
|
||||
<div v-if="memberValueView === 'json'" class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background p-5 text-[13px] leading-6">
|
||||
<RedisJsonTree :value="selectedMemberJsonDetail.value" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
|
||||
</div>
|
||||
<pre
|
||||
v-else
|
||||
class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background p-5 text-[13px] leading-6"
|
||||
:class="redisJsonWordWrap ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'"
|
||||
v-html="memberRawJsonHtml"
|
||||
></pre>
|
||||
<pre v-else class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background p-5 text-[13px] leading-6" :class="redisJsonWordWrap ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'" v-html="memberRawJsonHtml"></pre>
|
||||
</template>
|
||||
<pre
|
||||
v-else
|
||||
class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background p-5 text-[13px] leading-6 whitespace-pre-wrap break-words"
|
||||
>{{ selectedMemberDetail.text }}</pre
|
||||
>
|
||||
<pre v-else class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background p-5 text-[13px] leading-6 whitespace-pre-wrap break-words">{{ selectedMemberDetail.text }}</pre>
|
||||
<SheetFooter class="shrink-0 border-t px-5 py-3">
|
||||
<template v-if="isEditingMember">
|
||||
<Button variant="ghost" :disabled="savingMember" @click="cancelEditMember">
|
||||
|
|
|
|||
|
|
@ -55,17 +55,7 @@ type SearchTableTask = {
|
|||
table: TableInfo;
|
||||
};
|
||||
|
||||
const SYSTEM_SCHEMAS = new Set([
|
||||
"information_schema",
|
||||
"pg_catalog",
|
||||
"sys",
|
||||
"system",
|
||||
"mysql",
|
||||
"performance_schema",
|
||||
"xdb",
|
||||
"outln",
|
||||
"dbsnmp",
|
||||
]);
|
||||
const SYSTEM_SCHEMAS = new Set(["information_schema", "pg_catalog", "sys", "system", "mysql", "performance_schema", "xdb", "outln", "dbsnmp"]);
|
||||
const MAX_TABLES = 200;
|
||||
|
||||
const keyword = ref("");
|
||||
|
|
@ -82,18 +72,10 @@ const limitedTables = ref(false);
|
|||
const currentExecutionId = ref("");
|
||||
let runId = 0;
|
||||
|
||||
const connection = computed(() =>
|
||||
props.prefillConnectionId ? connectionStore.getConfig(props.prefillConnectionId) : undefined,
|
||||
);
|
||||
const scopeLabel = computed(() =>
|
||||
[connection.value?.name, props.prefillDatabase, props.prefillSchema].filter(Boolean).join(" / "),
|
||||
);
|
||||
const canSearch = computed(() =>
|
||||
Boolean(props.prefillConnectionId && props.prefillDatabase && keyword.value.trim() && !running.value),
|
||||
);
|
||||
const progressLabel = computed(() =>
|
||||
t("databaseSearch.progress", { done: progressDone.value, total: progressTotal.value }),
|
||||
);
|
||||
const connection = computed(() => (props.prefillConnectionId ? connectionStore.getConfig(props.prefillConnectionId) : undefined));
|
||||
const scopeLabel = computed(() => [connection.value?.name, props.prefillDatabase, props.prefillSchema].filter(Boolean).join(" / "));
|
||||
const canSearch = computed(() => Boolean(props.prefillConnectionId && props.prefillDatabase && keyword.value.trim() && !running.value));
|
||||
const progressLabel = computed(() => t("databaseSearch.progress", { done: progressDone.value, total: progressTotal.value }));
|
||||
|
||||
watch(
|
||||
dialogOpen,
|
||||
|
|
@ -160,11 +142,7 @@ function rowPreview(columns: string[], row: unknown[], matchedColumns: string[])
|
|||
async function listSearchTables(): Promise<SearchTableTask[]> {
|
||||
if (!connection.value || !props.prefillConnectionId || !props.prefillDatabase) return [];
|
||||
const databaseType = connection.value.db_type;
|
||||
const schemaNames = props.prefillSchema
|
||||
? [props.prefillSchema]
|
||||
: isSchemaAware(databaseType)
|
||||
? filterSearchSchemas(await api.listSchemas(props.prefillConnectionId, props.prefillDatabase))
|
||||
: [props.prefillDatabase];
|
||||
const schemaNames = props.prefillSchema ? [props.prefillSchema] : isSchemaAware(databaseType) ? filterSearchSchemas(await api.listSchemas(props.prefillConnectionId, props.prefillDatabase)) : [props.prefillDatabase];
|
||||
|
||||
const tasks: SearchTableTask[] = [];
|
||||
for (const schema of schemaNames) {
|
||||
|
|
@ -228,13 +206,7 @@ async function searchTable(task: SearchTableTask, databaseType: DatabaseType, cu
|
|||
|
||||
const executionId = makeExecutionId();
|
||||
currentExecutionId.value = executionId;
|
||||
const result = await api.executeQuery(
|
||||
props.prefillConnectionId,
|
||||
props.prefillDatabase,
|
||||
query.sql,
|
||||
undefined,
|
||||
executionId,
|
||||
);
|
||||
const result = await api.executeQuery(props.prefillConnectionId, props.prefillDatabase, query.sql, undefined, executionId);
|
||||
if (currentExecutionId.value === executionId) currentExecutionId.value = "";
|
||||
if (currentRun !== runId || cancelled.value) return;
|
||||
|
||||
|
|
@ -308,13 +280,7 @@ function openResult(item: SearchResultItem) {
|
|||
<div class="grid gap-3 md:grid-cols-[1fr_9rem_auto]">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("databaseSearch.keyword") }}</Label>
|
||||
<Input
|
||||
v-model="keyword"
|
||||
class="h-9"
|
||||
:placeholder="t('databaseSearch.keywordPlaceholder')"
|
||||
:disabled="running"
|
||||
@keydown.enter.prevent="startSearch"
|
||||
/>
|
||||
<Input v-model="keyword" class="h-9" :placeholder="t('databaseSearch.keywordPlaceholder')" :disabled="running" @keydown.enter.prevent="startSearch" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("databaseSearch.limitPerTable") }}</Label>
|
||||
|
|
@ -340,15 +306,10 @@ function openResult(item: SearchResultItem) {
|
|||
<div v-if="running || progressTotal" class="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>{{ loadingTables ? t("databaseSearch.loadingTables") : progressLabel }}</span>
|
||||
<span>{{ t("databaseSearch.resultCount", { count: results.length }) }}</span>
|
||||
<span v-if="limitedTables" class="text-amber-600">{{
|
||||
t("databaseSearch.limitedTables", { count: 200 })
|
||||
}}</span>
|
||||
<span v-if="limitedTables" class="text-amber-600">{{ t("databaseSearch.limitedTables", { count: 200 }) }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="generalError"
|
||||
class="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive"
|
||||
>
|
||||
<div v-if="generalError" class="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
<AlertCircle class="mt-0.5 h-4 w-4" />
|
||||
<span>{{ generalError }}</span>
|
||||
</div>
|
||||
|
|
@ -359,18 +320,11 @@ function openResult(item: SearchResultItem) {
|
|||
<Badge variant="outline">{{ results.length }}</Badge>
|
||||
</div>
|
||||
<div v-if="results.length" class="max-h-[360px] space-y-2 overflow-auto pr-1">
|
||||
<button
|
||||
v-for="item in results"
|
||||
:key="item.id"
|
||||
class="flex w-full items-start gap-3 rounded-md border bg-background px-3 py-2 text-left transition-colors hover:bg-muted/40"
|
||||
@click="openResult(item)"
|
||||
>
|
||||
<button v-for="item in results" :key="item.id" class="flex w-full items-start gap-3 rounded-md border bg-background px-3 py-2 text-left transition-colors hover:bg-muted/40" @click="openResult(item)">
|
||||
<Table2 class="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span class="truncate font-medium">{{
|
||||
item.schema ? `${item.schema}.${item.tableName}` : item.tableName
|
||||
}}</span>
|
||||
<span class="truncate font-medium">{{ item.schema ? `${item.schema}.${item.tableName}` : item.tableName }}</span>
|
||||
<Badge v-for="column in item.matchedColumns" :key="column" variant="secondary">{{ column }}</Badge>
|
||||
</div>
|
||||
<div class="mt-1 truncate text-xs text-muted-foreground">{{ item.preview }}</div>
|
||||
|
|
|
|||
|
|
@ -10,22 +10,8 @@ import { filterSidebarSearchRootsByConnectionState, filterSidebarTree } from "@/
|
|||
import { isCancelSearchShortcut } from "@/lib/keyboardShortcuts";
|
||||
import { usesTreeSchemaMode } from "@/lib/databaseFeatureSupport";
|
||||
import { connectionUsesDatabaseObjectTreeMode } from "@/lib/jdbcDialect";
|
||||
import {
|
||||
findSidebarNodeForActiveTab,
|
||||
findNodePathForActiveTab,
|
||||
scrollTopForSidebarNode,
|
||||
shouldScrollActiveSidebarSelection,
|
||||
} from "@/lib/sidebarActiveTabTarget";
|
||||
import {
|
||||
SIDEBAR_TREE_ROW_HEIGHT,
|
||||
SIDEBAR_TREE_PRERENDER_COUNT,
|
||||
SIDEBAR_TREE_SCROLL_BUFFER,
|
||||
flattenTree,
|
||||
scrollTopForExpandedTreeNode,
|
||||
shouldAutoScrollExpandedTreeNode,
|
||||
shouldVirtualizeFlatTree,
|
||||
type FlatTreeNode,
|
||||
} from "@/composables/useFlatTree";
|
||||
import { findSidebarNodeForActiveTab, findNodePathForActiveTab, scrollTopForSidebarNode, shouldScrollActiveSidebarSelection } from "@/lib/sidebarActiveTabTarget";
|
||||
import { SIDEBAR_TREE_ROW_HEIGHT, SIDEBAR_TREE_PRERENDER_COUNT, SIDEBAR_TREE_SCROLL_BUFFER, flattenTree, scrollTopForExpandedTreeNode, shouldAutoScrollExpandedTreeNode, shouldVirtualizeFlatTree, type FlatTreeNode } from "@/composables/useFlatTree";
|
||||
import { sidebarTreeContextKey } from "@/lib/sidebarTreeContext";
|
||||
import TreeItem from "./TreeItem.vue";
|
||||
import { RecycleScroller } from "vue-virtual-scroller";
|
||||
|
|
@ -156,11 +142,7 @@ const visibleNodeIndexById = computed(() => {
|
|||
});
|
||||
const useVirtualTree = computed(() => shouldVirtualizeFlatTree(flatNodes.value.length));
|
||||
const activeTab = computed(() => queryStore.tabs.find((tab) => tab.id === queryStore.activeTabId));
|
||||
const sidebarTreeOverflowClass = computed(() =>
|
||||
settingsStore.editorSettings.sidebarAllowHorizontalScroll
|
||||
? "overflow-x-auto sidebar-tree-horizontal-scroll"
|
||||
: "overflow-x-hidden",
|
||||
);
|
||||
const sidebarTreeOverflowClass = computed(() => (settingsStore.editorSettings.sidebarAllowHorizontalScroll ? "overflow-x-auto sidebar-tree-horizontal-scroll" : "overflow-x-hidden"));
|
||||
|
||||
provide(sidebarTreeContextKey, {
|
||||
getVisibleNodes: () => visibleNodes.value,
|
||||
|
|
@ -389,10 +371,7 @@ async function onNodeToggled(node: TreeNode, wasExpanded: boolean) {
|
|||
}
|
||||
|
||||
function currentTreeScroller(): HTMLElement | null {
|
||||
return (
|
||||
((useVirtualTree.value ? treeScrollerRef.value?.$el : plainTreeScrollerRef.value) as HTMLElement | undefined) ??
|
||||
null
|
||||
);
|
||||
return ((useVirtualTree.value ? treeScrollerRef.value?.$el : plainTreeScrollerRef.value) as HTMLElement | undefined) ?? null;
|
||||
}
|
||||
|
||||
async function selectActiveTabSidebarNode(options: { scroll: boolean }) {
|
||||
|
|
@ -467,19 +446,11 @@ defineExpose({ focusSearch, createNewGroup });
|
|||
:placeholder="t('grid.search')"
|
||||
@keydown="onSearchKeydown"
|
||||
/>
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
class="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
@click="searchQuery = ''"
|
||||
>
|
||||
<button v-if="searchQuery" class="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" @click="searchQuery = ''">
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="shrink-0 h-6 w-6 flex items-center justify-center rounded border border-border text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
:title="t('sidebar.locateActiveTab')"
|
||||
@click="locateActiveTabInSidebar"
|
||||
>
|
||||
<button class="shrink-0 h-6 w-6 flex items-center justify-center rounded border border-border text-muted-foreground hover:bg-accent hover:text-foreground" :title="t('sidebar.locateActiveTab')" @click="locateActiveTabInSidebar">
|
||||
<Crosshair class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<LightDropdown
|
||||
|
|
@ -491,12 +462,7 @@ defineExpose({ focusSearch, createNewGroup });
|
|||
:label="t('sidebar.filterByType')"
|
||||
:trigger-title="t('sidebar.filterByType')"
|
||||
:trigger-icon="ListFilter"
|
||||
:trigger-class="
|
||||
[
|
||||
'shrink-0 h-6 w-6 flex items-center justify-center rounded border border-border hover:bg-accent',
|
||||
hasSearchScopeFilter ? 'text-primary bg-primary/10 border-primary/30' : 'text-muted-foreground',
|
||||
].join(' ')
|
||||
"
|
||||
:trigger-class="['shrink-0 h-6 w-6 flex items-center justify-center rounded border border-border hover:bg-accent', hasSearchScopeFilter ? 'text-primary bg-primary/10 border-primary/30' : 'text-muted-foreground'].join(' ')"
|
||||
trigger-icon-class="h-3.5 w-3.5"
|
||||
item-icon-class="h-3.5 w-3.5"
|
||||
content-class="w-max min-w-0"
|
||||
|
|
@ -538,13 +504,7 @@ defineExpose({ focusSearch, createNewGroup });
|
|||
/>
|
||||
</template>
|
||||
</RecycleScroller>
|
||||
<div
|
||||
v-else-if="flatNodes.length > 0"
|
||||
ref="plainTreeScrollerRef"
|
||||
class="sidebar-tree min-h-0 flex-1 overflow-y-auto"
|
||||
:class="sidebarTreeOverflowClass"
|
||||
@click="clearSidebarSelection"
|
||||
>
|
||||
<div v-else-if="flatNodes.length > 0" ref="plainTreeScrollerRef" class="sidebar-tree min-h-0 flex-1 overflow-y-auto" :class="sidebarTreeOverflowClass" @click="clearSidebarSelection">
|
||||
<TreeItem
|
||||
v-for="item in flatNodes"
|
||||
:key="item.id"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -6,12 +6,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import {
|
||||
canSaveVisibleDatabaseSelection,
|
||||
filterDatabaseNamesForConnection,
|
||||
isSystemDatabaseName,
|
||||
normalizeVisibleDatabaseSelection,
|
||||
} from "@/lib/visibleDatabases";
|
||||
import { canSaveVisibleDatabaseSelection, filterDatabaseNamesForConnection, isSystemDatabaseName, normalizeVisibleDatabaseSelection } from "@/lib/visibleDatabases";
|
||||
import * as api from "@/lib/api";
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -47,9 +42,7 @@ const filteredDatabaseNames = computed(() => {
|
|||
const selectedCount = computed(() => selectedNames.value.size);
|
||||
const totalCount = computed(() => listedDatabaseNames.value.length);
|
||||
const canSaveSelection = computed(() => canSaveVisibleDatabaseSelection([...selectedNames.value]));
|
||||
const hasSystemDatabases = computed(() =>
|
||||
databaseNames.value.some((database) => isSystemDatabaseName(connection.value?.db_type, database)),
|
||||
);
|
||||
const hasSystemDatabases = computed(() => databaseNames.value.some((database) => isSystemDatabaseName(connection.value?.db_type, database)));
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
|
|
@ -61,9 +54,7 @@ watch(
|
|||
|
||||
watch(showSystemDatabases, (show) => {
|
||||
if (show) return;
|
||||
selectedNames.value = new Set(
|
||||
[...selectedNames.value].filter((database) => !isSystemDatabaseName(connection.value?.db_type, database)),
|
||||
);
|
||||
selectedNames.value = new Set([...selectedNames.value].filter((database) => !isSystemDatabaseName(connection.value?.db_type, database)));
|
||||
});
|
||||
|
||||
async function loadDatabases() {
|
||||
|
|
@ -74,13 +65,9 @@ async function loadDatabases() {
|
|||
const names = await loadDatabaseNames();
|
||||
databaseNames.value = names;
|
||||
const configured = connection.value?.visible_databases;
|
||||
const initialSelection = Array.isArray(configured)
|
||||
? normalizeVisibleDatabaseSelection(configured, names)
|
||||
: filterDatabaseNamesForConnection(names, connection.value);
|
||||
const initialSelection = Array.isArray(configured) ? normalizeVisibleDatabaseSelection(configured, names) : filterDatabaseNamesForConnection(names, connection.value);
|
||||
selectedNames.value = new Set(initialSelection);
|
||||
showSystemDatabases.value = initialSelection.some((database) =>
|
||||
isSystemDatabaseName(connection.value?.db_type, database),
|
||||
);
|
||||
showSystemDatabases.value = initialSelection.some((database) => isSystemDatabaseName(connection.value?.db_type, database));
|
||||
} catch (e: any) {
|
||||
databaseNames.value = [];
|
||||
selectedNames.value = new Set();
|
||||
|
|
@ -146,12 +133,7 @@ async function saveSelection() {
|
|||
|
||||
<div class="flex items-center gap-2 rounded-md border bg-background px-2">
|
||||
<Search class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<Input
|
||||
v-model="searchText"
|
||||
:placeholder="t('visibleDatabases.searchPlaceholder')"
|
||||
class="h-8 border-0 px-0 shadow-none focus-visible:ring-0"
|
||||
:disabled="isLoading || !!errorMessage"
|
||||
/>
|
||||
<Input v-model="searchText" :placeholder="t('visibleDatabases.searchPlaceholder')" class="h-8 border-0 px-0 shadow-none focus-visible:ring-0" :disabled="isLoading || !!errorMessage" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||
|
|
@ -163,11 +145,7 @@ async function saveSelection() {
|
|||
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoading" @click="clearSelection">
|
||||
{{ t("visibleDatabases.clear") }}
|
||||
</button>
|
||||
<button
|
||||
class="hover:text-foreground disabled:opacity-50"
|
||||
:disabled="isLoading || !Array.isArray(connection?.visible_databases)"
|
||||
@click="showAllDatabases"
|
||||
>
|
||||
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoading || !Array.isArray(connection?.visible_databases)" @click="showAllDatabases">
|
||||
{{ t("visibleDatabases.showAll") }}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -176,16 +154,8 @@ async function saveSelection() {
|
|||
{{ t("visibleDatabases.emptySelection") }}
|
||||
</p>
|
||||
|
||||
<label
|
||||
v-if="hasSystemDatabases"
|
||||
class="flex h-8 items-center gap-2 rounded-md px-1 text-xs text-muted-foreground"
|
||||
>
|
||||
<input
|
||||
v-model="showSystemDatabases"
|
||||
type="checkbox"
|
||||
class="h-3.5 w-3.5 accent-primary"
|
||||
:disabled="isLoading || !!errorMessage"
|
||||
/>
|
||||
<label v-if="hasSystemDatabases" class="flex h-8 items-center gap-2 rounded-md px-1 text-xs text-muted-foreground">
|
||||
<input v-model="showSystemDatabases" type="checkbox" class="h-3.5 w-3.5 accent-primary" :disabled="isLoading || !!errorMessage" />
|
||||
<span>{{ t("visibleDatabases.showSystemDatabases") }}</span>
|
||||
</label>
|
||||
|
||||
|
|
|
|||
|
|
@ -13,16 +13,7 @@ import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
|||
import { useToast } from "@/composables/useToast";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { databaseOptionsForConnection } from "@/composables/useDatabaseOptions";
|
||||
import {
|
||||
cancelSqlFileExecution,
|
||||
executeSqlFile,
|
||||
listenSqlFileProgress,
|
||||
listDatabases,
|
||||
previewSqlFile,
|
||||
type SqlFilePreview,
|
||||
type SqlFileProgress,
|
||||
type SqlFileStatus,
|
||||
} from "@/lib/api";
|
||||
import { cancelSqlFileExecution, executeSqlFile, listenSqlFileProgress, listDatabases, previewSqlFile, type SqlFilePreview, type SqlFileProgress, type SqlFileStatus } from "@/lib/api";
|
||||
import { Check, CheckSquare, FileCode, FolderOpen, Loader2, Play, Square, X } from "@lucide/vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
|
@ -58,22 +49,11 @@ const terminalStatus = ref<SqlFileStatus | "idle">("idle");
|
|||
const terminalError = ref("");
|
||||
const refreshedTarget = ref(false);
|
||||
|
||||
const sqlConnections = computed(() =>
|
||||
store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "etcd"].includes(c.db_type)),
|
||||
);
|
||||
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "etcd"].includes(c.db_type)));
|
||||
|
||||
const selectedConnection = computed(() => sqlConnections.value.find((c) => c.id === connectionId.value));
|
||||
|
||||
const canStart = computed(() =>
|
||||
Boolean(
|
||||
preview.value &&
|
||||
selectedConnection.value &&
|
||||
database.value.trim() &&
|
||||
!running.value &&
|
||||
!loadingPreview.value &&
|
||||
!loadingDatabases.value,
|
||||
),
|
||||
);
|
||||
const canStart = computed(() => Boolean(preview.value && selectedConnection.value && database.value.trim() && !running.value && !loadingPreview.value && !loadingDatabases.value));
|
||||
|
||||
const statusTone = computed(() => {
|
||||
if (terminalStatus.value === "done") return "text-green-600";
|
||||
|
|
@ -104,11 +84,7 @@ const previewIsTruncated = computed(() => {
|
|||
if (!preview.value) return false;
|
||||
return preview.value.sizeBytes > preview.value.preview.length;
|
||||
});
|
||||
const previewLineSummary = computed(() =>
|
||||
previewIsTruncated.value
|
||||
? t("sqlFile.previewingFirstLines", { count: previewLineCount.value })
|
||||
: t("sqlFile.previewingLines", { count: previewLineCount.value }),
|
||||
);
|
||||
const previewLineSummary = computed(() => (previewIsTruncated.value ? t("sqlFile.previewingFirstLines", { count: previewLineCount.value }) : t("sqlFile.previewingLines", { count: previewLineCount.value })));
|
||||
|
||||
function connectionIconType(id: string) {
|
||||
const config = store.getConfig(id);
|
||||
|
|
@ -424,19 +400,8 @@ watch(
|
|||
|
||||
<div class="flex items-center gap-2">
|
||||
<input ref="fileInput" type="file" accept=".sql,text/sql" class="hidden" @change="handleFileInputChange" />
|
||||
<Input
|
||||
:model-value="filePath"
|
||||
readonly
|
||||
class="h-8 text-xs font-mono"
|
||||
:placeholder="t('sqlFile.selectSqlFile')"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 shrink-0"
|
||||
:disabled="running || selectingFile"
|
||||
@click="selectFile"
|
||||
>
|
||||
<Input :model-value="filePath" readonly class="h-8 text-xs font-mono" :placeholder="t('sqlFile.selectSqlFile')" />
|
||||
<Button variant="outline" size="sm" class="h-8 shrink-0" :disabled="running || selectingFile" @click="selectFile">
|
||||
<Loader2 v-if="selectingFile || loadingPreview" class="w-3.5 h-3.5 mr-1.5 animate-spin" />
|
||||
<FolderOpen v-else class="w-3.5 h-3.5 mr-1.5" />
|
||||
{{ t("sqlFile.browse") }}
|
||||
|
|
@ -455,18 +420,11 @@ watch(
|
|||
<span>{{ formatBytes(preview.sizeBytes) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="sql-file-preview-viewer flex min-h-56 max-h-[min(42vh,360px)] max-w-full overflow-auto bg-muted/15 text-xs"
|
||||
>
|
||||
<div
|
||||
class="sticky left-0 z-10 select-none border-r bg-background/95 px-2 py-3 text-right font-mono leading-5 text-muted-foreground/70"
|
||||
>
|
||||
<div class="sql-file-preview-viewer flex min-h-56 max-h-[min(42vh,360px)] max-w-full overflow-auto bg-muted/15 text-xs">
|
||||
<div class="sticky left-0 z-10 select-none border-r bg-background/95 px-2 py-3 text-right font-mono leading-5 text-muted-foreground/70">
|
||||
<div v-for="lineNumber in previewLineNumbers" :key="lineNumber">{{ lineNumber }}</div>
|
||||
</div>
|
||||
<pre
|
||||
class="min-w-max flex-1 p-3 font-mono leading-5 whitespace-pre"
|
||||
v-html="highlight(preview.preview)"
|
||||
></pre>
|
||||
<pre class="min-w-max flex-1 p-3 font-mono leading-5 whitespace-pre" v-html="highlight(preview.preview)"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -509,16 +467,8 @@ watch(
|
|||
</SelectContent>
|
||||
</Select>
|
||||
<div v-else class="relative">
|
||||
<Input
|
||||
v-model="database"
|
||||
class="h-8 text-xs"
|
||||
:disabled="running || loadingDatabases"
|
||||
:placeholder="t('sqlFile.databasePlaceholder')"
|
||||
/>
|
||||
<Loader2
|
||||
v-if="loadingDatabases"
|
||||
class="absolute right-2 top-2 w-3.5 h-3.5 animate-spin text-muted-foreground"
|
||||
/>
|
||||
<Input v-model="database" class="h-8 text-xs" :disabled="running || loadingDatabases" :placeholder="t('sqlFile.databasePlaceholder')" />
|
||||
<Loader2 v-if="loadingDatabases" class="absolute right-2 top-2 w-3.5 h-3.5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -529,12 +479,7 @@ watch(
|
|||
{{ t("sqlFile.options") }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 text-xs text-left"
|
||||
:disabled="running"
|
||||
@click="continueOnError = !continueOnError"
|
||||
>
|
||||
<button type="button" class="flex items-center gap-2 text-xs text-left" :disabled="running" @click="continueOnError = !continueOnError">
|
||||
<CheckSquare v-if="continueOnError" class="w-3.5 h-3.5 text-primary shrink-0" />
|
||||
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
|
||||
{{ t("sqlFile.continueOnError") }}
|
||||
|
|
@ -555,17 +500,7 @@ watch(
|
|||
</div>
|
||||
|
||||
<div class="w-full bg-muted rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-300"
|
||||
:class="
|
||||
terminalStatus === 'error'
|
||||
? 'bg-destructive'
|
||||
: terminalStatus === 'cancelled'
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-primary'
|
||||
"
|
||||
:style="{ width: `${progressPercent}%` }"
|
||||
/>
|
||||
<div class="h-full rounded-full transition-all duration-300" :class="terminalStatus === 'error' ? 'bg-destructive' : terminalStatus === 'cancelled' ? 'bg-yellow-500' : 'bg-primary'" :style="{ width: `${progressPercent}%` }" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
|
||||
|
|
@ -595,17 +530,12 @@ watch(
|
|||
|
||||
<div v-if="progress?.statementSummary" class="space-y-1">
|
||||
<Label class="text-xs">{{ t("sqlFile.currentStatement") }}</Label>
|
||||
<div
|
||||
class="max-h-20 max-w-full overflow-auto rounded-md border bg-muted/15 p-2 text-xs font-mono whitespace-pre"
|
||||
>
|
||||
<div class="max-h-20 max-w-full overflow-auto rounded-md border bg-muted/15 p-2 text-xs font-mono whitespace-pre">
|
||||
{{ progress.statementSummary }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="progress?.error || terminalError"
|
||||
class="max-w-full overflow-auto rounded-md border bg-destructive/5 p-2 text-xs text-destructive whitespace-pre-wrap"
|
||||
>
|
||||
<div v-if="progress?.error || terminalError" class="max-w-full overflow-auto rounded-md border bg-destructive/5 p-2 text-xs text-destructive whitespace-pre-wrap">
|
||||
{{ progress?.error || terminalError }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,30 +6,8 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
Database,
|
||||
Info,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
Maximize2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Save,
|
||||
SlidersHorizontal,
|
||||
Trash2,
|
||||
X,
|
||||
} from "@lucide/vue";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { AlertTriangle, Check, ChevronDown, ChevronUp, Copy, Database, Info, KeyRound, Loader2, Maximize2, Plus, RefreshCw, Save, SlidersHorizontal, Trash2, X } from "@lucide/vue";
|
||||
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
|
|
@ -46,16 +24,7 @@ import { queryTimeoutSecsForConnection } from "@/lib/queryTimeout";
|
|||
import { type EditableStructureColumn, type EditableStructureIndex } from "@/lib/tableStructureEditorSql";
|
||||
import { getTableStructureCapabilities } from "@/lib/tableStructureCapabilities";
|
||||
import { connectionObjectTreeQuerySchema, effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect";
|
||||
import {
|
||||
buildStructureTargetLabel,
|
||||
combineDataTypeForDatabase,
|
||||
createColumnDrafts,
|
||||
createIndexDrafts,
|
||||
getDataTypeOptions,
|
||||
getDefaultLengthForType,
|
||||
splitDataType,
|
||||
toColumnNames,
|
||||
} from "@/lib/tableStructureEditorState";
|
||||
import { buildStructureTargetLabel, combineDataTypeForDatabase, createColumnDrafts, createIndexDrafts, getDataTypeOptions, getDefaultLengthForType, splitDataType, toColumnNames } from "@/lib/tableStructureEditorState";
|
||||
import type { ForeignKeyInfo, TriggerInfo } from "@/types/database";
|
||||
import * as api from "@/lib/api";
|
||||
|
||||
|
|
@ -229,16 +198,13 @@ const structureDensityStyle = computed(() => {
|
|||
"--structure-line-height": String(metric.lineHeight),
|
||||
};
|
||||
});
|
||||
const structureControlClass =
|
||||
"h-[var(--structure-control-height)] min-w-0 px-[var(--structure-control-px)] py-0 text-[length:var(--structure-font-size)]";
|
||||
const structureControlClass = "h-[var(--structure-control-height)] min-w-0 px-[var(--structure-control-px)] py-0 text-[length:var(--structure-font-size)]";
|
||||
const structureMonoControlClass = `${structureControlClass} font-mono`;
|
||||
const structureToolbarButtonClass =
|
||||
"h-[var(--structure-control-height)] gap-1 px-[var(--structure-control-px)] text-[length:var(--structure-font-size)]";
|
||||
const structureToolbarButtonClass = "h-[var(--structure-control-height)] gap-1 px-[var(--structure-control-px)] text-[length:var(--structure-font-size)]";
|
||||
const structureIconButtonClass = "h-[var(--structure-control-height)] w-[var(--structure-control-height)]";
|
||||
const structureIconClass = "h-[var(--structure-icon-size)] w-[var(--structure-icon-size)]";
|
||||
const structureCheckboxClass = "h-[var(--structure-checkbox-size)] w-[var(--structure-checkbox-size)]";
|
||||
const structureHeaderCellClass =
|
||||
"relative border-b border-r px-[var(--structure-cell-px)] py-[var(--structure-header-py)] text-left";
|
||||
const structureHeaderCellClass = "relative border-b border-r px-[var(--structure-cell-px)] py-[var(--structure-header-py)] text-left";
|
||||
const structureCellClass = "border-b border-r px-[var(--structure-cell-px)] py-[var(--structure-cell-py)]";
|
||||
const structureLastCellClass = "border-b px-[var(--structure-cell-px)] py-[var(--structure-cell-py)]";
|
||||
|
||||
|
|
@ -288,10 +254,7 @@ function onIndexColResize(e: MouseEvent, col: number) {
|
|||
const onMove = (ev: MouseEvent) => {
|
||||
if (!resizing.value) return;
|
||||
const delta = ev.clientX - resizing.value.startX;
|
||||
indexColWidths.value[col] = Math.max(
|
||||
structureDensityMetric.value.minIndexColumnWidth,
|
||||
resizing.value.startW + delta,
|
||||
);
|
||||
indexColWidths.value[col] = Math.max(structureDensityMetric.value.minIndexColumnWidth, resizing.value.startW + delta);
|
||||
};
|
||||
const onUp = () => {
|
||||
resizing.value = null;
|
||||
|
|
@ -316,20 +279,10 @@ const indexTypesByDb: Record<string, string[]> = {
|
|||
oracle: ["NORMAL", "BITMAP", "FUNCTION-BASED NORMAL", "FUNCTION-BASED DOMAIN", "DOMAIN", "CLUSTER"],
|
||||
sqlite: ["BTREE"],
|
||||
};
|
||||
const indexTypeOptions = computed(() =>
|
||||
structureCapabilities.value.indexType ? (indexTypesByDb[structureDialect.value] ?? []) : [],
|
||||
);
|
||||
const indexTypeOptions = computed(() => (structureCapabilities.value.indexType ? (indexTypesByDb[structureDialect.value] ?? []) : []));
|
||||
|
||||
function isPostgresIdentityType(dbType: string | undefined): boolean {
|
||||
return (
|
||||
dbType === "postgres" ||
|
||||
dbType === "gaussdb" ||
|
||||
dbType === "kwdb" ||
|
||||
dbType === "opengauss" ||
|
||||
dbType === "highgo" ||
|
||||
dbType === "vastbase" ||
|
||||
dbType === "kingbase"
|
||||
);
|
||||
return dbType === "postgres" || dbType === "gaussdb" || dbType === "kwdb" || dbType === "opengauss" || dbType === "highgo" || dbType === "vastbase" || dbType === "kingbase";
|
||||
}
|
||||
|
||||
const showExtendedProperties = computed(() => {
|
||||
|
|
@ -337,63 +290,28 @@ const showExtendedProperties = computed(() => {
|
|||
return dt === "mysql" || isPostgresIdentityType(dt) || dt === "sqlserver";
|
||||
});
|
||||
const extendedPropertiesColumnIndex = 8;
|
||||
const visibleColWidths = computed(() =>
|
||||
showExtendedProperties.value
|
||||
? colWidths.value
|
||||
: colWidths.value.filter((_, index) => index !== extendedPropertiesColumnIndex),
|
||||
);
|
||||
const visibleColWidths = computed(() => (showExtendedProperties.value ? colWidths.value : colWidths.value.filter((_, index) => index !== extendedPropertiesColumnIndex)));
|
||||
|
||||
function columnWidthIndex(visibleIndex: number) {
|
||||
return !showExtendedProperties.value && visibleIndex >= extendedPropertiesColumnIndex
|
||||
? visibleIndex + 1
|
||||
: visibleIndex;
|
||||
return !showExtendedProperties.value && visibleIndex >= extendedPropertiesColumnIndex ? visibleIndex + 1 : visibleIndex;
|
||||
}
|
||||
|
||||
const colLabels = computed(() => {
|
||||
const labels = [
|
||||
"#",
|
||||
t("structureEditor.columnName"),
|
||||
t("structureEditor.dataType"),
|
||||
t("structureEditor.length"),
|
||||
t("structureEditor.nullable"),
|
||||
t("structureEditor.primaryKey"),
|
||||
t("structureEditor.defaultValue"),
|
||||
t("structureEditor.comment"),
|
||||
];
|
||||
const labels = ["#", t("structureEditor.columnName"), t("structureEditor.dataType"), t("structureEditor.length"), t("structureEditor.nullable"), t("structureEditor.primaryKey"), t("structureEditor.defaultValue"), t("structureEditor.comment")];
|
||||
if (showExtendedProperties.value) {
|
||||
labels.push(t("structureEditor.extendedProperties"));
|
||||
}
|
||||
labels.push(t("structureEditor.actions"));
|
||||
return labels;
|
||||
});
|
||||
const indexColLabels = computed(() => [
|
||||
t("structureEditor.indexName"),
|
||||
t("structureEditor.indexColumns"),
|
||||
t("structureEditor.unique"),
|
||||
t("structureEditor.indexType"),
|
||||
t("structureEditor.includedColumns"),
|
||||
t("structureEditor.filter"),
|
||||
t("structureEditor.comment"),
|
||||
t("structureEditor.actions"),
|
||||
]);
|
||||
const indexColLabels = computed(() => [t("structureEditor.indexName"), t("structureEditor.indexColumns"), t("structureEditor.unique"), t("structureEditor.indexType"), t("structureEditor.includedColumns"), t("structureEditor.filter"), t("structureEditor.comment"), t("structureEditor.actions")]);
|
||||
const metadataSchema = computed(() => connectionObjectTreeQuerySchema(connection.value, props.database, props.schema));
|
||||
const refreshVersion = computed(() =>
|
||||
props.connectionId && props.tableName
|
||||
? queryStore.tableStructureRefreshVersion(props.connectionId, props.database, props.schema, props.tableName)
|
||||
: 0,
|
||||
);
|
||||
const refreshVersion = computed(() => (props.connectionId && props.tableName ? queryStore.tableStructureRefreshVersion(props.connectionId, props.database, props.schema, props.tableName) : 0));
|
||||
const isCreateMode = computed(() => !props.tableName);
|
||||
const newTableName = ref("");
|
||||
const tableComment = ref("");
|
||||
const originalTableComment = ref("");
|
||||
const targetLabel = computed(() =>
|
||||
buildStructureTargetLabel(
|
||||
connection.value?.name,
|
||||
props.database,
|
||||
props.schema,
|
||||
isCreateMode.value ? undefined : props.tableName,
|
||||
),
|
||||
);
|
||||
const targetLabel = computed(() => buildStructureTargetLabel(connection.value?.name, props.database, props.schema, isCreateMode.value ? undefined : props.tableName));
|
||||
|
||||
let sqlPreviewRequestId = 0;
|
||||
let keydownListenerRegistered = false;
|
||||
|
|
@ -411,9 +329,7 @@ async function refreshSqlPreview() {
|
|||
originalTableComment: isCreateMode.value ? undefined : originalTableComment.value,
|
||||
};
|
||||
try {
|
||||
const result = isCreateMode.value
|
||||
? await api.buildCreateTableSql(options)
|
||||
: await api.buildTableStructureChangeSql(options);
|
||||
const result = isCreateMode.value ? await api.buildCreateTableSql(options) : await api.buildTableStructureChangeSql(options);
|
||||
if (requestId !== sqlPreviewRequestId) return;
|
||||
pendingStatements.value = result.statements;
|
||||
warnings.value = result.warnings;
|
||||
|
|
@ -426,16 +342,7 @@ async function refreshSqlPreview() {
|
|||
}
|
||||
}
|
||||
|
||||
const canApply = computed(
|
||||
() =>
|
||||
!loading.value &&
|
||||
!saving.value &&
|
||||
!sqlPreviewLoading.value &&
|
||||
pendingStatements.value.length > 0 &&
|
||||
warnings.value.length === 0 &&
|
||||
!!props.connectionId &&
|
||||
(isCreateMode.value ? !!newTableName.value.trim() : !!props.tableName),
|
||||
);
|
||||
const canApply = computed(() => !loading.value && !saving.value && !sqlPreviewLoading.value && pendingStatements.value.length > 0 && warnings.value.length === 0 && !!props.connectionId && (isCreateMode.value ? !!newTableName.value.trim() : !!props.tableName));
|
||||
|
||||
function resetState() {
|
||||
activeTab.value = "columns";
|
||||
|
|
@ -474,9 +381,7 @@ async function loadStructure(silent = false) {
|
|||
triggers.value = nextTriggers;
|
||||
try {
|
||||
const tables = await api.listTables(props.connectionId, props.database, metadataSchema.value);
|
||||
const table = tables.find(
|
||||
(t) => t.name.toLowerCase() === props.tableName!.toLowerCase() && t.table_type !== "VIEW",
|
||||
);
|
||||
const table = tables.find((t) => t.name.toLowerCase() === props.tableName!.toLowerCase() && t.table_type !== "VIEW");
|
||||
originalTableComment.value = table?.comment || "";
|
||||
tableComment.value = table?.comment || "";
|
||||
} catch {
|
||||
|
|
@ -547,9 +452,7 @@ function isColumnTypeDisabled(column: EditableStructureColumn): boolean {
|
|||
}
|
||||
|
||||
function isColumnNullableDisabled(column: EditableStructureColumn): boolean {
|
||||
return (
|
||||
column.markedForDrop || column.isPrimaryKey || (!!column.original && !structureCapabilities.value.alterNullability)
|
||||
);
|
||||
return column.markedForDrop || column.isPrimaryKey || (!!column.original && !structureCapabilities.value.alterNullability);
|
||||
}
|
||||
|
||||
function isColumnDefaultDisabled(column: EditableStructureColumn): boolean {
|
||||
|
|
@ -634,11 +537,7 @@ function toggleDropIndex(index: EditableStructureIndex) {
|
|||
function canEditIndexDraft(index: EditableStructureIndex): boolean {
|
||||
if (index.markedForDrop || index.isPrimary) return false;
|
||||
if (!index.original) return structureCapabilities.value.createIndex;
|
||||
return (
|
||||
structureCapabilities.value.rebuildIndex &&
|
||||
structureCapabilities.value.createIndex &&
|
||||
structureCapabilities.value.dropIndex
|
||||
);
|
||||
return structureCapabilities.value.rebuildIndex && structureCapabilities.value.createIndex && structureCapabilities.value.dropIndex;
|
||||
}
|
||||
|
||||
function canEditIndexFilter(index: EditableStructureIndex): boolean {
|
||||
|
|
@ -661,13 +560,7 @@ function primarySqlOperation(sql: string): string {
|
|||
return statement?.match(/^([a-z]+)/i)?.[1]?.toUpperCase() || "SQL";
|
||||
}
|
||||
|
||||
async function recordStructureHistory(
|
||||
sql: string,
|
||||
start: number,
|
||||
success: boolean,
|
||||
result?: { affected_rows?: number },
|
||||
error?: string,
|
||||
) {
|
||||
async function recordStructureHistory(sql: string, start: number, success: boolean, result?: { affected_rows?: number }, error?: string) {
|
||||
const connection = store.getConfig(props.connectionId);
|
||||
try {
|
||||
await historyStore.add({
|
||||
|
|
@ -707,13 +600,7 @@ async function applyChanges() {
|
|||
try {
|
||||
const connection = store.getConfig(props.connectionId);
|
||||
const timeoutSecs = queryTimeoutSecsForConnection(connection);
|
||||
const result = await api.executeBatch(
|
||||
props.connectionId,
|
||||
props.database,
|
||||
pendingStatements.value,
|
||||
props.schema,
|
||||
timeoutSecs,
|
||||
);
|
||||
const result = await api.executeBatch(props.connectionId, props.database, pendingStatements.value, props.schema, timeoutSecs);
|
||||
await recordStructureHistory(sql, startedAt, true, result);
|
||||
toast(t("structureEditor.saved"), 2500);
|
||||
emit("saved", tableComment.value !== originalTableComment.value);
|
||||
|
|
@ -804,26 +691,12 @@ watch(activeTab, (tab) => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="rootRef"
|
||||
class="flex h-full min-h-0 flex-col gap-2 overflow-hidden p-[var(--structure-shell-padding)] text-[length:var(--structure-font-size)]"
|
||||
:data-structure-density="structureDensity"
|
||||
:style="structureDensityStyle"
|
||||
>
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-2 rounded-md border bg-muted/20 px-[var(--structure-cell-px)] py-[var(--structure-header-py)] text-[length:var(--structure-font-size)]"
|
||||
>
|
||||
<div ref="rootRef" class="flex h-full min-h-0 flex-col gap-2 overflow-hidden p-[var(--structure-shell-padding)] text-[length:var(--structure-font-size)]" :data-structure-density="structureDensity" :style="structureDensityStyle">
|
||||
<div class="flex shrink-0 items-center gap-2 rounded-md border bg-muted/20 px-[var(--structure-cell-px)] py-[var(--structure-header-py)] text-[length:var(--structure-font-size)]">
|
||||
<Database :class="[structureIconClass, 'text-muted-foreground']" />
|
||||
<span class="min-w-0 flex-1 truncate font-medium">{{ targetLabel || t("editor.noDatabase") }}</span>
|
||||
<Badge variant="outline">{{ connection?.driver_label || databaseType }}</Badge>
|
||||
<Button
|
||||
v-if="!isCreateMode"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:class="structureToolbarButtonClass"
|
||||
:disabled="loading || saving"
|
||||
@click="loadStructure()"
|
||||
>
|
||||
<Button v-if="!isCreateMode" variant="ghost" size="sm" :class="structureToolbarButtonClass" :disabled="loading || saving" @click="loadStructure()">
|
||||
<RefreshCw :class="structureIconClass" />
|
||||
{{ t("structureEditor.refresh") }}
|
||||
</Button>
|
||||
|
|
@ -831,21 +704,12 @@ watch(activeTab, (tab) => {
|
|||
|
||||
<div v-if="isCreateMode" class="flex shrink-0 items-center gap-2">
|
||||
<label class="shrink-0 font-medium text-muted-foreground">{{ t("structureEditor.tableName") }}</label>
|
||||
<Input
|
||||
v-model="newTableName"
|
||||
:placeholder="t('contextMenu.duplicateNamePlaceholder')"
|
||||
:class="[structureControlClass, 'max-w-[220px]']"
|
||||
/>
|
||||
<Input v-model="newTableName" :placeholder="t('contextMenu.duplicateNamePlaceholder')" :class="[structureControlClass, 'max-w-[220px]']" />
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<label class="shrink-0 font-medium text-muted-foreground">{{ t("structureEditor.comment") }}</label>
|
||||
<Input
|
||||
v-model="tableComment"
|
||||
:placeholder="t('structureEditor.tableCommentPlaceholder')"
|
||||
:class="[structureControlClass, 'max-w-[320px]']"
|
||||
:disabled="isTableCommentDisabled"
|
||||
/>
|
||||
<Input v-model="tableComment" :placeholder="t('structureEditor.tableCommentPlaceholder')" :class="[structureControlClass, 'max-w-[320px]']" :disabled="isTableCommentDisabled" />
|
||||
<Tooltip v-if="isTableCommentDisabled">
|
||||
<TooltipTrigger as-child>
|
||||
<Info :class="[structureIconClass, 'shrink-0 text-muted-foreground']" />
|
||||
|
|
@ -854,10 +718,7 @@ watch(activeTab, (tab) => {
|
|||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loading"
|
||||
class="flex min-h-0 flex-1 items-center justify-center gap-2 text-[length:var(--structure-font-size)] text-muted-foreground"
|
||||
>
|
||||
<div v-if="loading" class="flex min-h-0 flex-1 items-center justify-center gap-2 text-[length:var(--structure-font-size)] text-muted-foreground">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
{{ t("common.loading") }}
|
||||
</div>
|
||||
|
|
@ -877,11 +738,7 @@ watch(activeTab, (tab) => {
|
|||
<div class="flex items-center gap-1.5">
|
||||
<SlidersHorizontal :class="[structureIconClass, 'text-muted-foreground']" />
|
||||
<Select :model-value="structureDensity" @update:model-value="setStructureDensity">
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
class="h-[var(--structure-control-height)] w-[108px] rounded-md px-[var(--structure-control-px)] text-[length:var(--structure-font-size)]"
|
||||
:aria-label="t('structureEditor.density')"
|
||||
>
|
||||
<SelectTrigger size="sm" class="h-[var(--structure-control-height)] w-[108px] rounded-md px-[var(--structure-control-px)] text-[length:var(--structure-font-size)]" :aria-label="t('structureEditor.density')">
|
||||
<SelectValue :placeholder="t('structureEditor.density')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end" class="min-w-28">
|
||||
|
|
@ -891,23 +748,11 @@ watch(activeTab, (tab) => {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
v-if="activeTab === 'columns'"
|
||||
size="sm"
|
||||
:class="structureToolbarButtonClass"
|
||||
:disabled="!structureCapabilities.addColumn"
|
||||
@click="addColumn"
|
||||
>
|
||||
<Button v-if="activeTab === 'columns'" size="sm" :class="structureToolbarButtonClass" :disabled="!structureCapabilities.addColumn" @click="addColumn">
|
||||
<Plus :class="structureIconClass" />
|
||||
{{ t("structureEditor.addColumn") }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="activeTab === 'indexes'"
|
||||
size="sm"
|
||||
:class="structureToolbarButtonClass"
|
||||
:disabled="!structureCapabilities.createIndex"
|
||||
@click="addIndex"
|
||||
>
|
||||
<Button v-if="activeTab === 'indexes'" size="sm" :class="structureToolbarButtonClass" :disabled="!structureCapabilities.createIndex" @click="addIndex">
|
||||
<Plus :class="structureIconClass" />
|
||||
{{ t("structureEditor.addIndex") }}
|
||||
</Button>
|
||||
|
|
@ -915,35 +760,17 @@ watch(activeTab, (tab) => {
|
|||
</div>
|
||||
|
||||
<TabsContent value="columns" class="m-0 min-h-0 flex-1 overflow-auto p-0">
|
||||
<table
|
||||
class="border-separate border-spacing-0 text-[length:var(--structure-font-size)] leading-[var(--structure-line-height)]"
|
||||
:style="{ minWidth: visibleColWidths.reduce((a, w) => a + w, 0) + 'px' }"
|
||||
>
|
||||
<table class="border-separate border-spacing-0 text-[length:var(--structure-font-size)] leading-[var(--structure-line-height)]" :style="{ minWidth: visibleColWidths.reduce((a, w) => a + w, 0) + 'px' }">
|
||||
<thead class="sticky top-0 z-10 bg-background">
|
||||
<tr>
|
||||
<th
|
||||
v-for="(label, i) in colLabels"
|
||||
:key="i"
|
||||
:class="[structureHeaderCellClass, { 'text-center': i === 5 }]"
|
||||
:style="{ width: visibleColWidths[i] + 'px', minWidth: visibleColWidths[i] + 'px' }"
|
||||
>
|
||||
<th v-for="(label, i) in colLabels" :key="i" :class="[structureHeaderCellClass, { 'text-center': i === 5 }]" :style="{ width: visibleColWidths[i] + 'px', minWidth: visibleColWidths[i] + 'px' }">
|
||||
{{ label }}
|
||||
<div
|
||||
v-if="i < colLabels.length - 1"
|
||||
class="absolute right-0 top-0 z-20 h-full w-1 cursor-col-resize hover:bg-primary/30"
|
||||
:class="colResizing?.col === columnWidthIndex(i) ? 'bg-primary/30' : ''"
|
||||
@mousedown="onColResize($event, i)"
|
||||
/>
|
||||
<div v-if="i < colLabels.length - 1" class="absolute right-0 top-0 z-20 h-full w-1 cursor-col-resize hover:bg-primary/30" :class="colResizing?.col === columnWidthIndex(i) ? 'bg-primary/30' : ''" @mousedown="onColResize($event, i)" />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(column, index) in columns"
|
||||
:key="column.id"
|
||||
:class="column.markedForDrop ? 'bg-destructive/5 opacity-60' : ''"
|
||||
:data-new-column-row="!column.original ? 'true' : undefined"
|
||||
>
|
||||
<tr v-for="(column, index) in columns" :key="column.id" :class="column.markedForDrop ? 'bg-destructive/5 opacity-60' : ''" :data-new-column-row="!column.original ? 'true' : undefined">
|
||||
<td :class="[structureCellClass, 'text-muted-foreground']">
|
||||
<div class="flex items-center gap-1">
|
||||
<span>{{ index + 1 }}</span>
|
||||
|
|
@ -951,12 +778,7 @@ watch(activeTab, (tab) => {
|
|||
</div>
|
||||
</td>
|
||||
<td :class="structureCellClass">
|
||||
<Input
|
||||
v-model="column.name"
|
||||
:class="structureControlClass"
|
||||
:disabled="isColumnNameDisabled(column)"
|
||||
data-column-name-input
|
||||
/>
|
||||
<Input v-model="column.name" :class="structureControlClass" :disabled="isColumnNameDisabled(column)" data-column-name-input />
|
||||
</td>
|
||||
<td :class="structureCellClass">
|
||||
<SearchableSelect
|
||||
|
|
@ -969,44 +791,16 @@ watch(activeTab, (tab) => {
|
|||
:loading-text="t('common.loading')"
|
||||
:allow-custom="true"
|
||||
:trigger-class="[structureMonoControlClass, 'w-full']"
|
||||
@update:model-value="
|
||||
(v: string) =>
|
||||
(column.dataType = combineDataTypeForDatabase(
|
||||
databaseType,
|
||||
v,
|
||||
getDefaultLengthForType(databaseType, v),
|
||||
))
|
||||
"
|
||||
/>
|
||||
<Input
|
||||
v-else
|
||||
:model-value="splitDataType(column.dataType).baseType"
|
||||
:class="[structureMonoControlClass, 'w-full']"
|
||||
disabled
|
||||
@update:model-value="(v: string) => (column.dataType = combineDataTypeForDatabase(databaseType, v, getDefaultLengthForType(databaseType, v)))"
|
||||
/>
|
||||
<Input v-else :model-value="splitDataType(column.dataType).baseType" :class="[structureMonoControlClass, 'w-full']" disabled />
|
||||
</td>
|
||||
<td :class="structureCellClass">
|
||||
<Input
|
||||
:model-value="splitDataType(column.dataType).params"
|
||||
:class="structureMonoControlClass"
|
||||
:disabled="isColumnTypeDisabled(column)"
|
||||
@update:model-value="
|
||||
column.dataType = combineDataTypeForDatabase(
|
||||
databaseType,
|
||||
splitDataType(column.dataType).baseType,
|
||||
String($event),
|
||||
)
|
||||
"
|
||||
/>
|
||||
<Input :model-value="splitDataType(column.dataType).params" :class="structureMonoControlClass" :disabled="isColumnTypeDisabled(column)" @update:model-value="column.dataType = combineDataTypeForDatabase(databaseType, splitDataType(column.dataType).baseType, String($event))" />
|
||||
</td>
|
||||
<td :class="structureCellClass">
|
||||
<label class="flex items-center gap-1.5">
|
||||
<input
|
||||
v-model="column.isNullable"
|
||||
type="checkbox"
|
||||
:class="structureCheckboxClass"
|
||||
:disabled="isColumnNullableDisabled(column)"
|
||||
/>
|
||||
<input v-model="column.isNullable" type="checkbox" :class="structureCheckboxClass" :disabled="isColumnNullableDisabled(column)" />
|
||||
<span>{{ column.isNullable ? t("structureEditor.yes") : t("structureEditor.no") }}</span>
|
||||
</label>
|
||||
</td>
|
||||
|
|
@ -1024,29 +818,14 @@ watch(activeTab, (tab) => {
|
|||
/>
|
||||
</td>
|
||||
<td :class="structureCellClass">
|
||||
<Input
|
||||
v-model="column.defaultValue"
|
||||
:class="structureMonoControlClass"
|
||||
:disabled="isColumnDefaultDisabled(column)"
|
||||
/>
|
||||
<Input v-model="column.defaultValue" :class="structureMonoControlClass" :disabled="isColumnDefaultDisabled(column)" />
|
||||
</td>
|
||||
<td :class="structureCellClass">
|
||||
<div class="flex min-w-0 items-center gap-1">
|
||||
<Input
|
||||
v-model="column.comment"
|
||||
:class="[structureControlClass, 'flex-1']"
|
||||
:disabled="isColumnCommentDisabled(column)"
|
||||
/>
|
||||
<Input v-model="column.comment" :class="[structureControlClass, 'flex-1']" :disabled="isColumnCommentDisabled(column)" />
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:class="[structureIconButtonClass, 'shrink-0']"
|
||||
:disabled="isColumnCommentDisabled(column)"
|
||||
:aria-label="t('structureEditor.editComment')"
|
||||
:title="t('structureEditor.editComment')"
|
||||
>
|
||||
<Button variant="ghost" size="icon" :class="[structureIconButtonClass, 'shrink-0']" :disabled="isColumnCommentDisabled(column)" :aria-label="t('structureEditor.editComment')" :title="t('structureEditor.editComment')">
|
||||
<Maximize2 :class="structureIconClass" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
|
@ -1055,9 +834,7 @@ watch(activeTab, (tab) => {
|
|||
<span class="min-w-0 truncate text-xs font-medium">
|
||||
{{ t("structureEditor.editComment") }}
|
||||
</span>
|
||||
<span
|
||||
class="max-w-44 truncate font-mono text-[length:var(--structure-font-size)] text-muted-foreground"
|
||||
>
|
||||
<span class="max-w-44 truncate font-mono text-[length:var(--structure-font-size)] text-muted-foreground">
|
||||
{{ column.name || t("structureEditor.columnName") }}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -1080,11 +857,7 @@ watch(activeTab, (tab) => {
|
|||
{{ t("structureEditor.autoIncrement") }}
|
||||
</label>
|
||||
<label class="flex items-center gap-1 whitespace-nowrap">
|
||||
<input
|
||||
v-model="column.extra.onUpdateCurrentTimestamp"
|
||||
type="checkbox"
|
||||
:class="structureCheckboxClass"
|
||||
/>
|
||||
<input v-model="column.extra.onUpdateCurrentTimestamp" type="checkbox" :class="structureCheckboxClass" />
|
||||
{{ t("structureEditor.onUpdateCurrentTimestamp") }}
|
||||
</label>
|
||||
</template>
|
||||
|
|
@ -1106,9 +879,7 @@ watch(activeTab, (tab) => {
|
|||
}
|
||||
"
|
||||
>
|
||||
<SelectTrigger
|
||||
class="h-[var(--structure-control-height)] w-28 rounded-md px-[var(--structure-control-px)] text-[length:var(--structure-font-size)]"
|
||||
>
|
||||
<SelectTrigger class="h-[var(--structure-control-height)] w-28 rounded-md px-[var(--structure-control-px)] text-[length:var(--structure-font-size)]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -1184,47 +955,18 @@ watch(activeTab, (tab) => {
|
|||
<td :class="structureLastCellClass">
|
||||
<div class="flex items-center gap-1">
|
||||
<template v-if="canShowColumnMoveControls">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:class="structureIconButtonClass"
|
||||
:disabled="!canMoveColumn(index, -1)"
|
||||
:title="t('structureEditor.moveColumnUp')"
|
||||
:aria-label="t('structureEditor.moveColumnUp')"
|
||||
@click="moveColumn(index, -1)"
|
||||
>
|
||||
<Button variant="ghost" size="icon" :class="structureIconButtonClass" :disabled="!canMoveColumn(index, -1)" :title="t('structureEditor.moveColumnUp')" :aria-label="t('structureEditor.moveColumnUp')" @click="moveColumn(index, -1)">
|
||||
<ChevronUp :class="structureIconClass" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:class="structureIconButtonClass"
|
||||
:disabled="!canMoveColumn(index, 1)"
|
||||
:title="t('structureEditor.moveColumnDown')"
|
||||
:aria-label="t('structureEditor.moveColumnDown')"
|
||||
@click="moveColumn(index, 1)"
|
||||
>
|
||||
<Button variant="ghost" size="icon" :class="structureIconButtonClass" :disabled="!canMoveColumn(index, 1)" :title="t('structureEditor.moveColumnDown')" :aria-label="t('structureEditor.moveColumnDown')" @click="moveColumn(index, 1)">
|
||||
<ChevronDown :class="structureIconClass" />
|
||||
</Button>
|
||||
</template>
|
||||
<Button
|
||||
v-if="column.original"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:class="structureToolbarButtonClass"
|
||||
:disabled="!canDropColumn(column)"
|
||||
@click="toggleDropColumn(column)"
|
||||
>
|
||||
<Button v-if="column.original" variant="ghost" size="sm" :class="structureToolbarButtonClass" :disabled="!canDropColumn(column)" @click="toggleDropColumn(column)">
|
||||
<Trash2 :class="structureIconClass" />
|
||||
{{ column.markedForDrop ? t("structureEditor.restore") : t("structureEditor.drop") }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:class="structureToolbarButtonClass"
|
||||
@click="removeNewColumn(column)"
|
||||
>
|
||||
<Button v-else variant="ghost" size="sm" :class="structureToolbarButtonClass" @click="removeNewColumn(column)">
|
||||
<X :class="structureIconClass" />
|
||||
{{ t("structureEditor.remove") }}
|
||||
</Button>
|
||||
|
|
@ -1236,10 +978,7 @@ watch(activeTab, (tab) => {
|
|||
</TabsContent>
|
||||
|
||||
<TabsContent value="indexes" class="m-0 min-h-0 flex-1 overflow-auto p-0">
|
||||
<table
|
||||
class="border-separate border-spacing-0 text-[length:var(--structure-font-size)] leading-[var(--structure-line-height)]"
|
||||
:style="{ minWidth: indexColWidths.reduce((a, w) => a + w, 0) + 'px' }"
|
||||
>
|
||||
<table class="border-separate border-spacing-0 text-[length:var(--structure-font-size)] leading-[var(--structure-line-height)]" :style="{ minWidth: indexColWidths.reduce((a, w) => a + w, 0) + 'px' }">
|
||||
<thead class="sticky top-0 z-10 bg-background">
|
||||
<tr>
|
||||
<th
|
||||
|
|
@ -1252,182 +991,83 @@ watch(activeTab, (tab) => {
|
|||
}"
|
||||
>
|
||||
{{ label }}
|
||||
<div
|
||||
v-if="i < indexColLabels.length - 1"
|
||||
class="absolute right-0 top-0 z-20 h-full w-1 cursor-col-resize hover:bg-primary/30"
|
||||
:class="resizing?.col === i ? 'bg-primary/30' : ''"
|
||||
@mousedown="onIndexColResize($event, i)"
|
||||
/>
|
||||
<div v-if="i < indexColLabels.length - 1" class="absolute right-0 top-0 z-20 h-full w-1 cursor-col-resize hover:bg-primary/30" :class="resizing?.col === i ? 'bg-primary/30' : ''" @mousedown="onIndexColResize($event, i)" />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="index in indexes"
|
||||
:key="index.id"
|
||||
:class="index.markedForDrop ? 'bg-destructive/5 opacity-60' : ''"
|
||||
:data-new-index-row="!index.original ? 'true' : undefined"
|
||||
>
|
||||
<tr v-for="index in indexes" :key="index.id" :class="index.markedForDrop ? 'bg-destructive/5 opacity-60' : ''" :data-new-index-row="!index.original ? 'true' : undefined">
|
||||
<td :class="structureCellClass">
|
||||
<Input
|
||||
v-model="index.name"
|
||||
:class="structureControlClass"
|
||||
:disabled="!canEditIndexDraft(index)"
|
||||
data-index-name-input
|
||||
/>
|
||||
<Input v-model="index.name" :class="structureControlClass" :disabled="!canEditIndexDraft(index)" data-index-name-input />
|
||||
</td>
|
||||
<td :class="[structureCellClass, 'overflow-hidden']">
|
||||
<DropdownMenu v-if="canEditIndexDraft(index)">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="outline" :class="[structureMonoControlClass, 'w-full justify-between']">
|
||||
<span class="truncate">{{
|
||||
toColumnNames(index.columns) || t("structureEditor.indexColumnsPlaceholder")
|
||||
}}</span>
|
||||
<span class="truncate">{{ toColumnNames(index.columns) || t("structureEditor.indexColumnsPlaceholder") }}</span>
|
||||
<ChevronDown :class="[structureIconClass, 'ml-1 shrink-0 opacity-50']" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
class="max-h-56 min-w-44 overflow-y-auto"
|
||||
side="bottom"
|
||||
:side-offset="2"
|
||||
:avoid-collisions="false"
|
||||
@interactOutside="colSearch = ''"
|
||||
>
|
||||
<DropdownMenuContent class="max-h-56 min-w-44 overflow-y-auto" side="bottom" :side-offset="2" :avoid-collisions="false" @interactOutside="colSearch = ''">
|
||||
<div class="px-[var(--structure-cell-px)] pb-1 pt-0.5">
|
||||
<Input
|
||||
v-model="colSearch"
|
||||
:class="structureControlClass"
|
||||
:placeholder="t('grid.search')"
|
||||
@click.stop
|
||||
/>
|
||||
<Input v-model="colSearch" :class="structureControlClass" :placeholder="t('grid.search')" @click.stop />
|
||||
</div>
|
||||
<DropdownMenuCheckboxItem
|
||||
v-for="col in filteredColumnNames"
|
||||
:key="col"
|
||||
:checked="index.columns.includes(col)"
|
||||
:class="index.columns.includes(col) ? 'bg-primary/10' : ''"
|
||||
@select.prevent
|
||||
@click="toggleIndexColumn(index, col)"
|
||||
>
|
||||
<DropdownMenuCheckboxItem v-for="col in filteredColumnNames" :key="col" :checked="index.columns.includes(col)" :class="index.columns.includes(col) ? 'bg-primary/10' : ''" @select.prevent @click="toggleIndexColumn(index, col)">
|
||||
{{ col }}
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<span v-else class="font-mono text-[length:var(--structure-font-size)] text-muted-foreground">{{
|
||||
toColumnNames(index.columns)
|
||||
}}</span>
|
||||
<span v-else class="font-mono text-[length:var(--structure-font-size)] text-muted-foreground">{{ toColumnNames(index.columns) }}</span>
|
||||
</td>
|
||||
<td :class="structureCellClass">
|
||||
<label class="flex items-center gap-1.5">
|
||||
<input
|
||||
v-model="index.isUnique"
|
||||
type="checkbox"
|
||||
:class="structureCheckboxClass"
|
||||
:disabled="!canEditIndexDraft(index)"
|
||||
/>
|
||||
<input v-model="index.isUnique" type="checkbox" :class="structureCheckboxClass" :disabled="!canEditIndexDraft(index)" />
|
||||
<span>{{ index.isUnique ? t("structureEditor.yes") : t("structureEditor.no") }}</span>
|
||||
</label>
|
||||
</td>
|
||||
<td :class="structureCellClass">
|
||||
<Select
|
||||
v-if="indexTypeOptions.length > 0"
|
||||
:model-value="index.indexType || 'BTREE'"
|
||||
:disabled="!canEditIndexDraft(index)"
|
||||
@update:model-value="(v: any) => (index.indexType = String(v ?? ''))"
|
||||
>
|
||||
<SelectTrigger
|
||||
class="h-[var(--structure-control-height)] w-full rounded-md px-[var(--structure-control-px)] font-mono text-[length:var(--structure-font-size)]"
|
||||
>
|
||||
<Select v-if="indexTypeOptions.length > 0" :model-value="index.indexType || 'BTREE'" :disabled="!canEditIndexDraft(index)" @update:model-value="(v: any) => (index.indexType = String(v ?? ''))">
|
||||
<SelectTrigger class="h-[var(--structure-control-height)] w-full rounded-md px-[var(--structure-control-px)] font-mono text-[length:var(--structure-font-size)]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="opt in indexTypeOptions" :key="opt" :value="opt">{{ opt }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
v-else
|
||||
v-model="index.indexType"
|
||||
:class="structureMonoControlClass"
|
||||
placeholder="BTREE"
|
||||
:disabled="!canEditIndexDraft(index) || !structureCapabilities.indexType"
|
||||
/>
|
||||
<Input v-else v-model="index.indexType" :class="structureMonoControlClass" placeholder="BTREE" :disabled="!canEditIndexDraft(index) || !structureCapabilities.indexType" />
|
||||
</td>
|
||||
<td :class="[structureCellClass, 'overflow-hidden']">
|
||||
<DropdownMenu v-if="canEditIndexDraft(index) && structureCapabilities.indexInclude">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="outline" :class="[structureMonoControlClass, 'w-full justify-between']">
|
||||
<span class="truncate">{{
|
||||
index.includedColumns.join(", ") || t("structureEditor.includedColumnsPlaceholder")
|
||||
}}</span>
|
||||
<span class="truncate">{{ index.includedColumns.join(", ") || t("structureEditor.includedColumnsPlaceholder") }}</span>
|
||||
<ChevronDown :class="[structureIconClass, 'ml-1 shrink-0 opacity-50']" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
class="max-h-56 min-w-44 overflow-y-auto"
|
||||
side="bottom"
|
||||
:side-offset="2"
|
||||
:avoid-collisions="false"
|
||||
@interactOutside="colSearch = ''"
|
||||
>
|
||||
<DropdownMenuContent class="max-h-56 min-w-44 overflow-y-auto" side="bottom" :side-offset="2" :avoid-collisions="false" @interactOutside="colSearch = ''">
|
||||
<div class="px-[var(--structure-cell-px)] pb-1 pt-0.5">
|
||||
<Input
|
||||
v-model="colSearch"
|
||||
:class="structureControlClass"
|
||||
:placeholder="t('grid.search')"
|
||||
@click.stop
|
||||
/>
|
||||
<Input v-model="colSearch" :class="structureControlClass" :placeholder="t('grid.search')" @click.stop />
|
||||
</div>
|
||||
<DropdownMenuCheckboxItem
|
||||
v-for="col in filteredColumnNames"
|
||||
:key="col"
|
||||
:checked="index.includedColumns.includes(col)"
|
||||
:class="index.includedColumns.includes(col) ? 'bg-primary/10' : ''"
|
||||
@select.prevent
|
||||
@click="toggleIncludedColumn(index, col)"
|
||||
>
|
||||
<DropdownMenuCheckboxItem v-for="col in filteredColumnNames" :key="col" :checked="index.includedColumns.includes(col)" :class="index.includedColumns.includes(col) ? 'bg-primary/10' : ''" @select.prevent @click="toggleIncludedColumn(index, col)">
|
||||
{{ col }}
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<span v-else class="text-[length:var(--structure-font-size)] text-muted-foreground">{{
|
||||
index.includedColumns.join(", ")
|
||||
}}</span>
|
||||
<span v-else class="text-[length:var(--structure-font-size)] text-muted-foreground">{{ index.includedColumns.join(", ") }}</span>
|
||||
</td>
|
||||
<td :class="structureCellClass">
|
||||
<Input
|
||||
v-model="index.filter"
|
||||
:class="structureMonoControlClass"
|
||||
:placeholder="index.original?.filter || ''"
|
||||
:disabled="!canEditIndexFilter(index)"
|
||||
/>
|
||||
<Input v-model="index.filter" :class="structureMonoControlClass" :placeholder="index.original?.filter || ''" :disabled="!canEditIndexFilter(index)" />
|
||||
</td>
|
||||
<td :class="structureCellClass">
|
||||
<Input
|
||||
v-model="index.comment"
|
||||
:class="structureControlClass"
|
||||
:disabled="!canEditIndexComment(index)"
|
||||
/>
|
||||
<Input v-model="index.comment" :class="structureControlClass" :disabled="!canEditIndexComment(index)" />
|
||||
</td>
|
||||
<td :class="structureLastCellClass">
|
||||
<Badge v-if="index.isPrimary" variant="outline">{{ t("structureEditor.primary") }}</Badge>
|
||||
<Button
|
||||
v-else-if="index.original"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:class="structureToolbarButtonClass"
|
||||
:disabled="!canDropIndex(index)"
|
||||
@click="toggleDropIndex(index)"
|
||||
>
|
||||
<Button v-else-if="index.original" variant="ghost" size="sm" :class="structureToolbarButtonClass" :disabled="!canDropIndex(index)" @click="toggleDropIndex(index)">
|
||||
<Trash2 :class="structureIconClass" />
|
||||
{{ index.markedForDrop ? t("structureEditor.restore") : t("structureEditor.drop") }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:class="structureToolbarButtonClass"
|
||||
@click="removeNewIndex(index)"
|
||||
>
|
||||
<Button v-else variant="ghost" size="sm" :class="structureToolbarButtonClass" @click="removeNewIndex(index)">
|
||||
<X :class="structureIconClass" />
|
||||
{{ t("structureEditor.remove") }}
|
||||
</Button>
|
||||
|
|
@ -1442,15 +1082,9 @@ watch(activeTab, (tab) => {
|
|||
{{ t("structureEditor.emptyReadonly") }}
|
||||
</div>
|
||||
<div v-else class="space-y-1.5">
|
||||
<div
|
||||
v-for="fk in foreignKeys"
|
||||
:key="fk.name"
|
||||
class="rounded-md border px-[var(--structure-cell-px)] py-[var(--structure-header-py)] text-[length:var(--structure-font-size)]"
|
||||
>
|
||||
<div v-for="fk in foreignKeys" :key="fk.name" class="rounded-md border px-[var(--structure-cell-px)] py-[var(--structure-header-py)] text-[length:var(--structure-font-size)]">
|
||||
<div class="font-medium">{{ fk.name }}</div>
|
||||
<div class="mt-1 font-mono text-muted-foreground">
|
||||
{{ fk.column }} -> {{ fk.ref_table }}.{{ fk.ref_column }}
|
||||
</div>
|
||||
<div class="mt-1 font-mono text-muted-foreground">{{ fk.column }} -> {{ fk.ref_table }}.{{ fk.ref_column }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
|
@ -1460,11 +1094,7 @@ watch(activeTab, (tab) => {
|
|||
{{ t("structureEditor.emptyReadonly") }}
|
||||
</div>
|
||||
<div v-else class="space-y-1.5">
|
||||
<div
|
||||
v-for="trigger in triggers"
|
||||
:key="trigger.name"
|
||||
class="rounded-md border px-[var(--structure-cell-px)] py-[var(--structure-header-py)] text-[length:var(--structure-font-size)]"
|
||||
>
|
||||
<div v-for="trigger in triggers" :key="trigger.name" class="rounded-md border px-[var(--structure-cell-px)] py-[var(--structure-header-py)] text-[length:var(--structure-font-size)]">
|
||||
<div class="font-medium">{{ trigger.name }}</div>
|
||||
<div class="mt-1 font-mono text-muted-foreground">{{ trigger.timing }} {{ trigger.event }}</div>
|
||||
</div>
|
||||
|
|
@ -1476,37 +1106,22 @@ watch(activeTab, (tab) => {
|
|||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
{{ t("common.loading") }}
|
||||
</div>
|
||||
<pre
|
||||
v-else
|
||||
class="m-0 min-h-0 flex-1 whitespace-pre p-3 font-mono text-xs leading-5 select-text"
|
||||
v-html="ddlContent ? (sqlHighlighter?.(ddlContent) ?? ddlContent) : t('structureEditor.emptyReadonly')"
|
||||
></pre>
|
||||
<pre v-else class="m-0 min-h-0 flex-1 whitespace-pre p-3 font-mono text-xs leading-5 select-text" v-html="ddlContent ? (sqlHighlighter?.(ddlContent) ?? ddlContent) : t('structureEditor.emptyReadonly')"></pre>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div class="flex h-[28%] min-h-40 min-w-0 max-h-64 shrink-0 flex-col overflow-hidden rounded-md border">
|
||||
<div
|
||||
class="flex shrink-0 items-center justify-between border-b px-[var(--structure-cell-px)] py-[var(--structure-header-py)] text-[length:var(--structure-font-size)] font-medium"
|
||||
>
|
||||
<div class="flex shrink-0 items-center justify-between border-b px-[var(--structure-cell-px)] py-[var(--structure-header-py)] text-[length:var(--structure-font-size)] font-medium">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span>{{ t("structureEditor.sqlPreview") }}</span>
|
||||
<Badge
|
||||
v-if="!saving && pendingStatements.length && warnings.length === 0"
|
||||
variant="outline"
|
||||
class="h-4 px-1 text-[10px]"
|
||||
>
|
||||
<Badge v-if="!saving && pendingStatements.length && warnings.length === 0" variant="outline" class="h-4 px-1 text-[10px]">
|
||||
<Check class="h-3 w-3" />
|
||||
{{ t("structureEditor.ready") }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
:class="structureToolbarButtonClass"
|
||||
:disabled="!previewSqlText.trim()"
|
||||
@click="copyPreviewSql"
|
||||
>
|
||||
<Button variant="ghost" :class="structureToolbarButtonClass" :disabled="!previewSqlText.trim()" @click="copyPreviewSql">
|
||||
<Copy :class="[structureIconClass, 'mr-1']" />
|
||||
{{ t("structureEditor.copySql") }}
|
||||
</Button>
|
||||
|
|
@ -1518,34 +1133,20 @@ watch(activeTab, (tab) => {
|
|||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto p-2.5">
|
||||
<div v-if="warnings.length" class="mb-2 space-y-1">
|
||||
<div
|
||||
v-for="warning in warnings"
|
||||
:key="warning"
|
||||
class="flex gap-1.5 rounded-md border border-yellow-300/40 bg-yellow-500/10 px-[var(--structure-cell-px)] py-[var(--structure-cell-py)] text-[length:var(--structure-font-size)] text-yellow-700 dark:text-yellow-300"
|
||||
>
|
||||
<div v-for="warning in warnings" :key="warning" class="flex gap-1.5 rounded-md border border-yellow-300/40 bg-yellow-500/10 px-[var(--structure-cell-px)] py-[var(--structure-cell-py)] text-[length:var(--structure-font-size)] text-yellow-700 dark:text-yellow-300">
|
||||
<AlertTriangle :class="[structureIconClass, 'mt-0.5 shrink-0']" />
|
||||
<span>{{ warning }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<pre
|
||||
v-if="pendingStatements.length"
|
||||
class="select-text whitespace-pre-wrap break-words rounded-md bg-muted/40 p-2.5 font-mono text-[calc(var(--structure-font-size)+1px)] leading-5"
|
||||
v-html="highlightedSql"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-full items-center justify-center text-[length:var(--structure-font-size)] text-muted-foreground"
|
||||
>
|
||||
<pre v-if="pendingStatements.length" class="select-text whitespace-pre-wrap break-words rounded-md bg-muted/40 p-2.5 font-mono text-[calc(var(--structure-font-size)+1px)] leading-5" v-html="highlightedSql" />
|
||||
<div v-else class="flex h-full items-center justify-center text-[length:var(--structure-font-size)] text-muted-foreground">
|
||||
{{ t("structureEditor.noChanges") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="shrink-0 rounded-md border border-destructive/30 bg-destructive/10 px-[var(--structure-cell-px)] py-[var(--structure-header-py)] text-[length:var(--structure-font-size)] text-destructive"
|
||||
>
|
||||
<div v-if="errorMessage" class="shrink-0 rounded-md border border-destructive/30 bg-destructive/10 px-[var(--structure-cell-px)] py-[var(--structure-header-py)] text-[length:var(--structure-font-size)] text-destructive">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -66,9 +66,7 @@ const filteredTables = computed(() => {
|
|||
return q ? sourceTables.value.filter((t) => t.toLowerCase().includes(q)) : sourceTables.value;
|
||||
});
|
||||
|
||||
const allSelected = computed(
|
||||
() => filteredTables.value.length > 0 && filteredTables.value.every((t) => selectedTables.value.has(t)),
|
||||
);
|
||||
const allSelected = computed(() => filteredTables.value.length > 0 && filteredTables.value.every((t) => selectedTables.value.has(t)));
|
||||
|
||||
function connectionType(id: string): DatabaseType | undefined {
|
||||
return store.connections.find((c) => c.id === id)?.db_type;
|
||||
|
|
@ -78,15 +76,7 @@ function isMongoConnection(id: string): boolean {
|
|||
return connectionType(id) === "mongodb";
|
||||
}
|
||||
|
||||
const canStart = computed(
|
||||
() =>
|
||||
sourceConnectionId.value &&
|
||||
sourceDatabase.value &&
|
||||
targetConnectionId.value &&
|
||||
targetDatabase.value &&
|
||||
selectedTables.value.size > 0 &&
|
||||
sourceConnectionId.value + sourceDatabase.value !== targetConnectionId.value + targetDatabase.value,
|
||||
);
|
||||
const canStart = computed(() => sourceConnectionId.value && sourceDatabase.value && targetConnectionId.value && targetDatabase.value && selectedTables.value.size > 0 && sourceConnectionId.value + sourceDatabase.value !== targetConnectionId.value + targetDatabase.value);
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (allSelected.value) {
|
||||
|
|
@ -108,9 +98,7 @@ async function loadDatabases(connectionId: string, target: "source" | "target")
|
|||
if (!connectionId) return;
|
||||
try {
|
||||
await store.ensureConnected(connectionId);
|
||||
const rawNames = isMongoConnection(connectionId)
|
||||
? await api.mongoListDatabases(connectionId)
|
||||
: (await api.listDatabases(connectionId)).map((d) => d.name);
|
||||
const rawNames = isMongoConnection(connectionId) ? await api.mongoListDatabases(connectionId) : (await api.listDatabases(connectionId)).map((d) => d.name);
|
||||
const names = databaseOptionsForConnection(rawNames, store.getConfig(connectionId));
|
||||
if (target === "source") {
|
||||
sourceDatabases.value = names;
|
||||
|
|
@ -139,12 +127,7 @@ async function loadSchemas(connectionId: string, database: string, side: "source
|
|||
}
|
||||
try {
|
||||
const schemas = await api.listSchemas(connectionId, database);
|
||||
const selected =
|
||||
preferredSchema && schemas.includes(preferredSchema)
|
||||
? preferredSchema
|
||||
: schemas.includes("public")
|
||||
? "public"
|
||||
: (schemas[0] ?? "");
|
||||
const selected = preferredSchema && schemas.includes(preferredSchema) ? preferredSchema : schemas.includes("public") ? "public" : (schemas[0] ?? "");
|
||||
if (side === "source") {
|
||||
sourceSchemas.value = schemas;
|
||||
sourceSchema.value = selected;
|
||||
|
|
@ -179,9 +162,7 @@ async function loadTables() {
|
|||
const needsSchema = isSchemaAware(config?.db_type);
|
||||
const schema = needsSchema && sourceSchema.value ? sourceSchema.value : sourceDatabase.value;
|
||||
const tables = await api.listTables(sourceConnectionId.value, sourceDatabase.value, schema);
|
||||
sourceTables.value = tables
|
||||
.filter((t) => t.table_type === "TABLE" || t.table_type === "BASE TABLE")
|
||||
.map((t) => t.name);
|
||||
sourceTables.value = tables.filter((t) => t.table_type === "TABLE" || t.table_type === "BASE TABLE").map((t) => t.name);
|
||||
selectedTables.value = new Set(sourceTables.value);
|
||||
} catch {
|
||||
sourceTables.value = [];
|
||||
|
|
@ -358,25 +339,15 @@ function formatTableRows(progress: TransferProgress) {
|
|||
return formatRowCount(progress.rowsTransferred);
|
||||
}
|
||||
|
||||
const completedTables = computed(
|
||||
() => [...transferProgress.value.values()].filter((p) => processedStatuses.has(p.status)).length,
|
||||
);
|
||||
const completedTables = computed(() => [...transferProgress.value.values()].filter((p) => processedStatuses.has(p.status)).length);
|
||||
|
||||
const failedTables = computed(() => [...transferProgress.value.values()].filter((p) => p.status === "error").length);
|
||||
|
||||
const totalTransferred = computed(() =>
|
||||
[...transferProgress.value.values()].reduce((sum, p) => sum + p.rowsTransferred, 0),
|
||||
);
|
||||
const totalTransferred = computed(() => [...transferProgress.value.values()].reduce((sum, p) => sum + p.rowsTransferred, 0));
|
||||
|
||||
const knownTotalRows = computed(() =>
|
||||
[...transferProgress.value.values()].reduce((sum, p) => sum + (typeof p.totalRows === "number" ? p.totalRows : 0), 0),
|
||||
);
|
||||
const knownTotalRows = computed(() => [...transferProgress.value.values()].reduce((sum, p) => sum + (typeof p.totalRows === "number" ? p.totalRows : 0), 0));
|
||||
|
||||
const overallRowsLabel = computed(() =>
|
||||
knownTotalRows.value > 0
|
||||
? `${formatRowCount(totalTransferred.value)} / ${formatRowCount(knownTotalRows.value)}`
|
||||
: formatRowCount(totalTransferred.value),
|
||||
);
|
||||
const overallRowsLabel = computed(() => (knownTotalRows.value > 0 ? `${formatRowCount(totalTransferred.value)} / ${formatRowCount(knownTotalRows.value)}` : formatRowCount(totalTransferred.value)));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -505,48 +476,27 @@ const overallRowsLabel = computed(() =>
|
|||
<div class="flex items-center justify-between">
|
||||
<div class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{{ t("transfer.tables") }}
|
||||
<span v-if="sourceTables.length" class="text-muted-foreground/60"
|
||||
>({{ selectedTables.size }}/{{ sourceTables.length }})</span
|
||||
>
|
||||
<span v-if="sourceTables.length" class="text-muted-foreground/60">({{ selectedTables.size }}/{{ sourceTables.length }})</span>
|
||||
</div>
|
||||
<Button
|
||||
v-if="sourceTables.length"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 text-xs px-2"
|
||||
@click="toggleSelectAll"
|
||||
>
|
||||
<Button v-if="sourceTables.length" variant="ghost" size="sm" class="h-6 text-xs px-2" @click="toggleSelectAll">
|
||||
{{ allSelected ? t("transfer.deselectAll") : t("transfer.selectAll") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
v-if="sourceTables.length > 5"
|
||||
v-model="tableSearch"
|
||||
:placeholder="t('transfer.searchTables')"
|
||||
class="h-7 text-xs"
|
||||
/>
|
||||
<Input v-if="sourceTables.length > 5" v-model="tableSearch" :placeholder="t('transfer.searchTables')" class="h-7 text-xs" />
|
||||
|
||||
<div v-if="loadingTables" class="flex items-center gap-2 text-xs text-muted-foreground py-4 justify-center">
|
||||
<Loader2 class="w-3.5 h-3.5 animate-spin" />
|
||||
{{ t("common.loading") }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!sourceConnectionId || !sourceDatabase"
|
||||
class="text-xs text-muted-foreground py-4 text-center"
|
||||
>
|
||||
<div v-else-if="!sourceConnectionId || !sourceDatabase" class="text-xs text-muted-foreground py-4 text-center">
|
||||
{{ t("transfer.selectSourceFirst") }}
|
||||
</div>
|
||||
<div v-else-if="sourceTables.length === 0" class="text-xs text-muted-foreground py-4 text-center">
|
||||
{{ t("transfer.noTables") }}
|
||||
</div>
|
||||
<div v-else class="border rounded-md max-h-[200px] overflow-y-auto">
|
||||
<div
|
||||
v-for="table in filteredTables"
|
||||
:key="table"
|
||||
class="flex items-center gap-2 px-2.5 py-1.5 hover:bg-muted/50 cursor-pointer text-xs"
|
||||
@click="toggleTable(table)"
|
||||
>
|
||||
<div v-for="table in filteredTables" :key="table" class="flex items-center gap-2 px-2.5 py-1.5 hover:bg-muted/50 cursor-pointer text-xs" @click="toggleTable(table)">
|
||||
<CheckSquare v-if="selectedTables.has(table)" class="w-3.5 h-3.5 text-primary shrink-0" />
|
||||
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
|
||||
<span class="truncate">{{ table }}</span>
|
||||
|
|
@ -576,14 +526,7 @@ const overallRowsLabel = computed(() =>
|
|||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<Label class="text-xs shrink-0">{{ t("transfer.batchSize") }}</Label>
|
||||
<Input
|
||||
v-model.number="batchSize"
|
||||
type="number"
|
||||
min="100"
|
||||
max="10000"
|
||||
step="100"
|
||||
class="h-7 text-xs w-24"
|
||||
/>
|
||||
<Input v-model.number="batchSize" type="number" min="100" max="10000" step="100" class="h-7 text-xs w-24" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -592,13 +535,10 @@ const overallRowsLabel = computed(() =>
|
|||
<div v-else class="py-3 space-y-3">
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{{ t("transfer.overallProgress") }}: {{ completedTables }} / {{ selectedTables.size }}
|
||||
{{ t("transfer.tables").toLowerCase() }} · {{ overallRowsLabel }}
|
||||
{{ t("transfer.overallProgress") }}: {{ completedTables }} / {{ selectedTables.size }} {{ t("transfer.tables").toLowerCase() }} · {{ overallRowsLabel }}
|
||||
{{ t("grid.rows", { count: "" }).trim() }}
|
||||
</span>
|
||||
<span v-if="overallDone && !failedTables" class="text-green-600 font-medium">{{
|
||||
t("transfer.completed")
|
||||
}}</span>
|
||||
<span v-if="overallDone && !failedTables" class="text-green-600 font-medium">{{ t("transfer.completed") }}</span>
|
||||
<span v-else-if="overallDone && failedTables" class="text-amber-600 font-medium">
|
||||
{{ t("transfer.completedWithErrors", { count: failedTables }) }}
|
||||
</span>
|
||||
|
|
@ -609,15 +549,7 @@ const overallRowsLabel = computed(() =>
|
|||
<div class="w-full bg-muted rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-300"
|
||||
:class="
|
||||
overallError
|
||||
? 'bg-destructive'
|
||||
: overallCancelled
|
||||
? 'bg-yellow-500'
|
||||
: overallDone && failedTables
|
||||
? 'bg-amber-500'
|
||||
: 'bg-primary'
|
||||
"
|
||||
:class="overallError ? 'bg-destructive' : overallCancelled ? 'bg-yellow-500' : overallDone && failedTables ? 'bg-amber-500' : 'bg-primary'"
|
||||
:style="{
|
||||
width: `${selectedTables.size ? (completedTables / selectedTables.size) * 100 : 0}%`,
|
||||
}"
|
||||
|
|
@ -625,11 +557,7 @@ const overallRowsLabel = computed(() =>
|
|||
</div>
|
||||
|
||||
<div class="border rounded-md max-h-[280px] overflow-y-auto">
|
||||
<div
|
||||
v-for="table in [...selectedTables]"
|
||||
:key="table"
|
||||
class="flex items-center justify-between px-2.5 py-1.5 text-xs border-b last:border-b-0"
|
||||
>
|
||||
<div v-for="table in [...selectedTables]" :key="table" class="flex items-center justify-between px-2.5 py-1.5 text-xs border-b last:border-b-0">
|
||||
<span class="truncate">{{ table }}</span>
|
||||
<div class="flex items-center gap-1.5 shrink-0 text-muted-foreground">
|
||||
<template v-if="transferProgress.get(table)">
|
||||
|
|
@ -637,22 +565,14 @@ const overallRowsLabel = computed(() =>
|
|||
<Loader2 class="w-3 h-3 animate-spin text-primary" />
|
||||
<span>{{ formatTableRows(transferProgress.get(table)!) }}</span>
|
||||
</template>
|
||||
<template
|
||||
v-else-if="
|
||||
transferProgress.get(table)!.status === 'tableDone' ||
|
||||
transferProgress.get(table)!.status === 'done'
|
||||
"
|
||||
>
|
||||
<template v-else-if="transferProgress.get(table)!.status === 'tableDone' || transferProgress.get(table)!.status === 'done'">
|
||||
<Check class="w-3 h-3 text-green-500" />
|
||||
<span>{{ formatTableRows(transferProgress.get(table)!) }}</span>
|
||||
</template>
|
||||
<template v-else-if="transferProgress.get(table)!.status === 'error'">
|
||||
<X class="w-3 h-3 text-destructive" />
|
||||
<span>{{ formatTableRows(transferProgress.get(table)!) }}</span>
|
||||
<span
|
||||
class="max-w-[520px] whitespace-normal break-words text-destructive"
|
||||
:title="transferProgress.get(table)!.error ?? ''"
|
||||
>
|
||||
<span class="max-w-[520px] whitespace-normal break-words text-destructive" :title="transferProgress.get(table)!.error ?? ''">
|
||||
{{ transferProgress.get(table)!.error }}
|
||||
</span>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -210,9 +210,7 @@ function adjustSubPosition() {
|
|||
function itemButtonClass(variant?: "default" | "destructive") {
|
||||
return [
|
||||
"w-full gap-2 rounded-md px-2 py-1 text-[13px] leading-4 outline-hidden select-none text-left cursor-default flex items-center disabled:pointer-events-none disabled:opacity-50 transition-colors",
|
||||
variant === "destructive"
|
||||
? "text-destructive hover:bg-destructive/10 hover:text-destructive focus-visible:bg-destructive/10 focus-visible:text-destructive"
|
||||
: "hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground",
|
||||
variant === "destructive" ? "text-destructive hover:bg-destructive/10 hover:text-destructive focus-visible:bg-destructive/10 focus-visible:text-destructive" : "hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground",
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -245,39 +243,19 @@ onBeforeUnmount(() => {
|
|||
<slot :on-context-menu="onContextMenu" />
|
||||
<!-- Main menu -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="show"
|
||||
ref="menuRef"
|
||||
:style="{ position: 'fixed', left: x + 'px', top: y + 'px', zIndex: 9999 }"
|
||||
class="bg-popover text-popover-foreground min-w-40 rounded-xl p-1 overflow-x-hidden overflow-y-auto ring-1 ring-foreground/10 shadow-lg"
|
||||
>
|
||||
<div v-if="show" ref="menuRef" :style="{ position: 'fixed', left: x + 'px', top: y + 'px', zIndex: 9999 }" class="bg-popover text-popover-foreground min-w-40 rounded-xl p-1 overflow-x-hidden overflow-y-auto ring-1 ring-foreground/10 shadow-lg">
|
||||
<template v-for="(item, index) in items" :key="index">
|
||||
<template v-if="item.visible !== false">
|
||||
<div v-if="item.separator" class="-mx-1 my-1 flex items-center px-1">
|
||||
<div class="h-px flex-1 bg-border/70" />
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
:disabled="item.disabled"
|
||||
:class="[
|
||||
...itemButtonClass(item.variant),
|
||||
activeSubIndex === index ? 'bg-accent text-accent-foreground' : '',
|
||||
]"
|
||||
@click="handleItemClick(item)"
|
||||
@mouseenter="(e) => onItemMouseEnter(index, e)"
|
||||
@mouseleave="onItemMouseLeave"
|
||||
>
|
||||
<button v-else :disabled="item.disabled" :class="[...itemButtonClass(item.variant), activeSubIndex === index ? 'bg-accent text-accent-foreground' : '']" @click="handleItemClick(item)" @mouseenter="(e) => onItemMouseEnter(index, e)" @mouseleave="onItemMouseLeave">
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<component :is="item.icon" v-if="item.icon" :class="['size-4', item.iconClass]" />
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate">{{ item.label }}</span>
|
||||
<span v-if="item.shortcut" class="ml-8 inline-flex shrink-0 items-center gap-1 text-muted-foreground">
|
||||
<kbd
|
||||
v-for="key in shortcutKeys(item.shortcut)"
|
||||
:key="key"
|
||||
class="min-w-4 rounded border border-border/70 bg-muted/60 px-1 py-0.5 text-center font-mono text-[10px] leading-none text-muted-foreground shadow-xs"
|
||||
>{{ key }}</kbd
|
||||
>
|
||||
<kbd v-for="key in shortcutKeys(item.shortcut)" :key="key" class="min-w-4 rounded border border-border/70 bg-muted/60 px-1 py-0.5 text-center font-mono text-[10px] leading-none text-muted-foreground shadow-xs">{{ key }}</kbd>
|
||||
</span>
|
||||
<ChevronRight v-if="item.children?.length" class="ml-auto size-4 text-muted-foreground/80" />
|
||||
</button>
|
||||
|
|
@ -300,23 +278,13 @@ onBeforeUnmount(() => {
|
|||
<div v-if="child.separator" class="-mx-1 my-1 flex items-center px-1">
|
||||
<div class="h-px flex-1 bg-border/70" />
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
:disabled="child.disabled"
|
||||
:class="itemButtonClass(child.variant)"
|
||||
@click="handleSubItemClick(child)"
|
||||
>
|
||||
<button v-else :disabled="child.disabled" :class="itemButtonClass(child.variant)" @click="handleSubItemClick(child)">
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<component :is="child.icon" v-if="child.icon" :class="['size-4', child.iconClass]" />
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate">{{ child.label }}</span>
|
||||
<span v-if="child.shortcut" class="ml-8 inline-flex shrink-0 items-center gap-1 text-muted-foreground">
|
||||
<kbd
|
||||
v-for="key in shortcutKeys(child.shortcut)"
|
||||
:key="key"
|
||||
class="min-w-4 rounded border border-border/70 bg-muted/60 px-1 py-0.5 text-center font-mono text-[10px] leading-none text-muted-foreground shadow-xs"
|
||||
>{{ key }}</kbd
|
||||
>
|
||||
<kbd v-for="key in shortcutKeys(child.shortcut)" :key="key" class="min-w-4 rounded border border-border/70 bg-muted/60 px-1 py-0.5 text-center font-mono text-[10px] leading-none text-muted-foreground shadow-xs">{{ key }}</kbd>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -42,8 +42,7 @@ const props = withDefaults(
|
|||
{
|
||||
ariaLabel: undefined,
|
||||
contentClass: "",
|
||||
triggerClass:
|
||||
"flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
triggerClass: "flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
triggerTitle: undefined,
|
||||
triggerIcon: undefined,
|
||||
triggerLabel: undefined,
|
||||
|
|
@ -161,28 +160,13 @@ onBeforeUnmount(close);
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
ref="triggerRef"
|
||||
type="button"
|
||||
:class="triggerClass"
|
||||
:title="triggerTitle ?? selectedItem?.title"
|
||||
:aria-label="ariaLabel"
|
||||
:aria-expanded="open"
|
||||
@click="toggle"
|
||||
>
|
||||
<button ref="triggerRef" type="button" :class="triggerClass" :title="triggerTitle ?? selectedItem?.title" :aria-label="ariaLabel" :aria-expanded="open" @click="toggle">
|
||||
<component :is="triggerIcon" v-if="triggerIcon" :class="triggerIconClass" />
|
||||
<span v-if="showTriggerLabel">{{ triggerLabel ?? selectedItem?.label }}</span>
|
||||
<ChevronDown v-if="showChevron" class="h-3 w-3 opacity-50" />
|
||||
</button>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
ref="menuRef"
|
||||
class="fixed z-50 min-w-32 rounded-lg p-1 cn-menu-translucent text-popover-foreground"
|
||||
:class="contentClass"
|
||||
:style="menuStyle"
|
||||
role="menu"
|
||||
>
|
||||
<div v-if="open" ref="menuRef" class="fixed z-50 min-w-32 rounded-lg p-1 cn-menu-translucent text-popover-foreground" :class="contentClass" :style="menuStyle" role="menu">
|
||||
<div v-if="label" :class="labelClass">{{ label }}</div>
|
||||
<div v-if="label" class="bg-border -mx-1 my-1 h-px" />
|
||||
<template v-for="item in items" :key="item.value">
|
||||
|
|
@ -196,28 +180,13 @@ onBeforeUnmount(close);
|
|||
role="menuitem"
|
||||
@click="selectItem(item)"
|
||||
>
|
||||
<Check
|
||||
v-if="checkPosition === 'left'"
|
||||
class="h-3 w-3 shrink-0"
|
||||
:class="[isItemSelected(item) ? selectedCheckClass : 'opacity-0']"
|
||||
/>
|
||||
<span
|
||||
v-if="item.leadingText"
|
||||
class="inline-flex h-5 w-6 shrink-0 items-center justify-center text-sm font-medium leading-none"
|
||||
>
|
||||
<Check v-if="checkPosition === 'left'" class="h-3 w-3 shrink-0" :class="[isItemSelected(item) ? selectedCheckClass : 'opacity-0']" />
|
||||
<span v-if="item.leadingText" class="inline-flex h-5 w-6 shrink-0 items-center justify-center text-sm font-medium leading-none">
|
||||
{{ item.leadingText }}
|
||||
</span>
|
||||
<component
|
||||
:is="item.icon"
|
||||
v-if="item.icon"
|
||||
:class="[itemIconClass, 'shrink-0 text-muted-foreground', item.iconClass]"
|
||||
/>
|
||||
<component :is="item.icon" v-if="item.icon" :class="[itemIconClass, 'shrink-0 text-muted-foreground', item.iconClass]" />
|
||||
<span class="truncate">{{ item.label }}</span>
|
||||
<Check
|
||||
v-if="checkPosition === 'right' && isItemSelected(item)"
|
||||
class="ml-auto h-4 w-4 shrink-0"
|
||||
:class="selectedCheckClass"
|
||||
/>
|
||||
<Check v-if="checkPosition === 'right' && isItemSelected(item)" class="ml-auto h-4 w-4 shrink-0" :class="selectedCheckClass" />
|
||||
</button>
|
||||
</template>
|
||||
<slot />
|
||||
|
|
|
|||
|
|
@ -166,27 +166,11 @@ watch(
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
ref="triggerRef"
|
||||
class="contents"
|
||||
@mouseenter="scheduleOpen"
|
||||
@mouseleave="close"
|
||||
@focusin="scheduleFocusOpen"
|
||||
@focusout="close"
|
||||
>
|
||||
<span ref="triggerRef" class="contents" @mouseenter="scheduleOpen" @mouseleave="close" @focusin="scheduleFocusOpen" @focusout="close">
|
||||
<slot />
|
||||
</span>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="show"
|
||||
class="pointer-events-none fixed z-50 rounded-md bg-foreground text-xs text-background"
|
||||
:class="[
|
||||
slots.content ? '' : 'inline-flex w-fit max-w-xs items-center gap-1.5 px-3 py-1.5',
|
||||
tooltipTransformClass,
|
||||
]"
|
||||
:style="{ left: `${x}px`, top: `${y}px` }"
|
||||
role="tooltip"
|
||||
>
|
||||
<div v-if="show" class="pointer-events-none fixed z-50 rounded-md bg-foreground text-xs text-background" :class="[slots.content ? '' : 'inline-flex w-fit max-w-xs items-center gap-1.5 px-3 py-1.5', tooltipTransformClass]" :style="{ left: `${x}px`, top: `${y}px` }" role="tooltip">
|
||||
<slot name="content">{{ text }}</slot>
|
||||
<span :class="[arrowClass, 'size-2.5 rotate-45 rounded-[2px] bg-foreground']" aria-hidden="true" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -31,14 +31,7 @@ function isTooltipDisabled(): boolean {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<LightTooltip
|
||||
:text="text"
|
||||
:delay="delay"
|
||||
:disabled="isTooltipDisabled"
|
||||
:side="side"
|
||||
:side-offset="sideOffset"
|
||||
:open-on-focus="openOnFocus"
|
||||
>
|
||||
<LightTooltip :text="text" :delay="delay" :disabled="isTooltipDisabled" :side="side" :side-offset="sideOffset" :open-on-focus="openOnFocus">
|
||||
<span ref="textRef" :class="cn('truncate', props.class)">
|
||||
<slot>{{ text }}</slot>
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -18,12 +18,7 @@ const delegatedProps = reactiveOmit(props, "class");
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="badge"
|
||||
:data-variant="variant"
|
||||
:class="cn(badgeVariants({ variant }), props.class)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<Primitive data-slot="badge" :data-variant="variant" :class="cn(badgeVariants({ variant }), props.class)" v-bind="delegatedProps">
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ export const badgeVariants = cva(
|
|||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20",
|
||||
destructive: "bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20",
|
||||
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
|
|
|
|||
|
|
@ -18,14 +18,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="button"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||
>
|
||||
<Primitive data-slot="button" :data-variant="variant" :data-size="size" :as="as" :as-child="asChild" :class="cn(buttonVariants({ variant, size }), props.class)">
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -9,14 +9,10 @@ export const buttonVariants = cva(
|
|||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
destructive:
|
||||
"bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
|
||||
outline: "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
|
|
@ -25,8 +21,7 @@ export const buttonVariants = cva(
|
|||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*=size-])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*=size-])]:size-3",
|
||||
"icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*=size-])]:size-3",
|
||||
"icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -11,12 +11,7 @@ const delegatedProps = reactiveOmit(props, "class");
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuLabel
|
||||
data-slot="context-menu-label"
|
||||
:data-inset="inset ? '' : undefined"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7', props.class)"
|
||||
>
|
||||
<ContextMenuLabel data-slot="context-menu-label" :data-inset="inset ? '' : undefined" v-bind="delegatedProps" :class="cn('text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7', props.class)">
|
||||
<slot />
|
||||
</ContextMenuLabel>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -11,9 +11,5 @@ const delegatedProps = reactiveOmit(props, "class");
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuSeparator
|
||||
data-slot="context-menu-separator"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('bg-foreground/6 -mx-1 my-0.5 h-px', props.class)"
|
||||
/>
|
||||
<ContextMenuSeparator data-slot="context-menu-separator" v-bind="delegatedProps" :class="cn('bg-foreground/6 -mx-1 my-0.5 h-px', props.class)" />
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -8,15 +8,7 @@ const props = defineProps<{
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
data-slot="context-menu-shortcut"
|
||||
:class="
|
||||
cn(
|
||||
'text-muted-foreground group-focus/context-menu-item:text-accent-foreground ml-auto text-xs tracking-widest',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<span data-slot="context-menu-shortcut" :class="cn('text-muted-foreground group-focus/context-menu-item:text-accent-foreground ml-auto text-xs tracking-widest', props.class)">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -4,14 +4,7 @@ import type { DialogContentEmits, DialogContentProps } from "reka-ui";
|
|||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { XIcon } from "@lucide/vue";
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogPortal,
|
||||
VisuallyHidden,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui";
|
||||
import { DialogClose, DialogContent, DialogDescription, DialogPortal, VisuallyHidden, useForwardPropsEmits } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import DialogOverlay from "./DialogOverlay.vue";
|
||||
|
|
@ -20,12 +13,9 @@ defineOptions({
|
|||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<DialogContentProps & { class?: HTMLAttributes["class"]; showCloseButton?: boolean }>(),
|
||||
{
|
||||
showCloseButton: true,
|
||||
},
|
||||
);
|
||||
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"]; showCloseButton?: boolean }>(), {
|
||||
showCloseButton: true,
|
||||
});
|
||||
const emits = defineEmits<DialogContentEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
|
|
|||
|
|
@ -13,16 +13,7 @@ const forwardedProps = useForwardProps(delegatedProps);
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<DialogDescription
|
||||
data-slot="dialog-description"
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<DialogDescription data-slot="dialog-description" v-bind="forwardedProps" :class="cn('text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3', props.class)">
|
||||
<slot />
|
||||
</DialogDescription>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -16,15 +16,7 @@ const props = withDefaults(
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
:class="
|
||||
cn(
|
||||
'bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<div data-slot="dialog-footer" :class="cn('bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)">
|
||||
<slot />
|
||||
<DialogClose v-if="showCloseButton" as-child>
|
||||
<Button variant="outline"> Close </Button>
|
||||
|
|
|
|||
|
|
@ -11,16 +11,7 @@ const delegatedProps = reactiveOmit(props, "class");
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<DialogOverlay
|
||||
data-slot="dialog-overlay"
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-open:supports-backdrop-filter:backdrop-blur-xs bg-black/10 duration-100 fixed inset-0 isolate z-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<DialogOverlay data-slot="dialog-overlay" v-bind="delegatedProps" :class="cn('data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-open:supports-backdrop-filter:backdrop-blur-xs bg-black/10 duration-100 fixed inset-0 isolate z-50', props.class)">
|
||||
<slot />
|
||||
</DialogOverlay>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -21,16 +21,9 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
|||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/10 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=open]:supports-backdrop-filter:backdrop-blur-xs"
|
||||
>
|
||||
<DialogOverlay class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/10 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=open]:supports-backdrop-filter:backdrop-blur-xs">
|
||||
<DialogContent
|
||||
:class="
|
||||
cn(
|
||||
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-4 shadow-lg duration-200 sm:rounded-lg md:w-full',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
:class="cn('relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-4 shadow-lg duration-200 sm:rounded-lg md:w-full', props.class)"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
@pointer-down-outside="
|
||||
(event) => {
|
||||
|
|
|
|||
|
|
@ -13,11 +13,7 @@ const forwardedProps = useForwardProps(delegatedProps);
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTitle
|
||||
data-slot="dialog-title"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('text-base leading-none font-medium cn-font-heading', props.class)"
|
||||
>
|
||||
<DialogTitle data-slot="dialog-title" v-bind="forwardedProps" :class="cn('text-base leading-none font-medium cn-font-heading', props.class)">
|
||||
<slot />
|
||||
</DialogTitle>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -36,10 +36,7 @@ function guardRepeatedClick(event: MouseEvent) {
|
|||
)
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="absolute right-2 flex items-center justify-center pointer-events-none"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<span class="absolute right-2 flex items-center justify-center pointer-events-none" data-slot="dropdown-menu-checkbox-item-indicator">
|
||||
<DropdownMenuItemIndicator>
|
||||
<slot name="indicator-icon">
|
||||
<CheckIcon />
|
||||
|
|
|
|||
|
|
@ -12,12 +12,7 @@ const forwardedProps = useForwardProps(delegatedProps);
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
:data-inset="inset ? '' : undefined"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7', props.class)"
|
||||
>
|
||||
<DropdownMenuLabel data-slot="dropdown-menu-label" :data-inset="inset ? '' : undefined" v-bind="forwardedProps" :class="cn('text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7', props.class)">
|
||||
<slot />
|
||||
</DropdownMenuLabel>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -37,10 +37,7 @@ function guardRepeatedClick(event: MouseEvent) {
|
|||
)
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="absolute right-2 flex items-center justify-center pointer-events-none"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<span class="absolute right-2 flex items-center justify-center pointer-events-none" data-slot="dropdown-menu-radio-item-indicator">
|
||||
<DropdownMenuItemIndicator>
|
||||
<slot name="indicator-icon">
|
||||
<CheckIcon />
|
||||
|
|
|
|||
|
|
@ -15,9 +15,5 @@ const delegatedProps = reactiveOmit(props, "class");
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSeparator
|
||||
data-slot="dropdown-menu-separator"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('bg-border -mx-1 my-1 h-px', props.class)"
|
||||
/>
|
||||
<DropdownMenuSeparator data-slot="dropdown-menu-separator" v-bind="delegatedProps" :class="cn('bg-border -mx-1 my-1 h-px', props.class)" />
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -8,15 +8,7 @@ const props = defineProps<{
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
:class="
|
||||
cn(
|
||||
'text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<span data-slot="dropdown-menu-shortcut" :class="cn('text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest', props.class)">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -11,16 +11,7 @@ const delegatedProps = reactiveOmit(props, "class");
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<Label
|
||||
data-slot="label"
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'gap-2 text-sm leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<Label data-slot="label" v-bind="delegatedProps" :class="cn('gap-2 text-sm leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed', props.class)">
|
||||
<slot />
|
||||
</Label>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -13,10 +13,7 @@ const delegatedProps = reactiveOmit(props, "class");
|
|||
|
||||
<template>
|
||||
<ScrollAreaRoot data-slot="scroll-area" v-bind="delegatedProps" :class="cn('relative', props.class)">
|
||||
<ScrollAreaViewport
|
||||
data-slot="scroll-area-viewport"
|
||||
class="size-full overflow-auto rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
>
|
||||
<ScrollAreaViewport data-slot="scroll-area-viewport" class="size-full overflow-auto rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1">
|
||||
<slot />
|
||||
</ScrollAreaViewport>
|
||||
<ScrollBar />
|
||||
|
|
|
|||
|
|
@ -17,12 +17,7 @@ const delegatedProps = reactiveOmit(props, "class");
|
|||
data-slot="scroll-area-scrollbar"
|
||||
:data-orientation="orientation"
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent flex touch-none p-px transition-colors select-none',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
:class="cn('data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent flex touch-none p-px transition-colors select-none', props.class)"
|
||||
>
|
||||
<ScrollAreaThumb data-slot="scroll-area-thumb" class="rounded-full relative flex-1 bg-border" />
|
||||
</ScrollAreaScrollbar>
|
||||
|
|
|
|||
|
|
@ -49,9 +49,7 @@ const selectedLabel = computed(() => {
|
|||
|
||||
const filteredOptions = computed(() => filterDatabaseOptions(props.options, searchText.value, props.displayName));
|
||||
const customOptionValue = computed(() => props.normalizeCustom(searchText.value.trim()));
|
||||
const canSelectCustom = computed(
|
||||
() => props.allowCustom && !!customOptionValue.value && !props.options.includes(customOptionValue.value),
|
||||
);
|
||||
const canSelectCustom = computed(() => props.allowCustom && !!customOptionValue.value && !props.options.includes(customOptionValue.value));
|
||||
|
||||
watch(open, async (value) => {
|
||||
emit("update:open", value);
|
||||
|
|
@ -120,16 +118,7 @@ function handleKeydown(event: KeyboardEvent) {
|
|||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
:class="
|
||||
cn(
|
||||
'h-6 w-auto max-w-56 justify-between gap-1 border-0 bg-transparent px-1 text-xs font-normal shadow-none hover:bg-muted/50 focus-visible:ring-0',
|
||||
triggerClass,
|
||||
)
|
||||
"
|
||||
>
|
||||
<Button type="button" variant="ghost" :class="cn('h-6 w-auto max-w-56 justify-between gap-1 border-0 bg-transparent px-1 text-xs font-normal shadow-none hover:bg-muted/50 focus-visible:ring-0', triggerClass)">
|
||||
<slot name="trigger-label" :value="modelValue" :label="selectedLabel" :loading="loading">
|
||||
<span class="truncate">{{ loading ? loadingText : selectedLabel }}</span>
|
||||
</slot>
|
||||
|
|
@ -139,14 +128,7 @@ function handleKeydown(event: KeyboardEvent) {
|
|||
<PopoverContent align="end" :class="cn('w-52 gap-1 p-1.5', contentClass)">
|
||||
<div class="flex items-center gap-1.5 rounded-sm border bg-background px-2">
|
||||
<Search class="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<Input
|
||||
ref="searchInput"
|
||||
:model-value="searchText"
|
||||
:placeholder="searchPlaceholder"
|
||||
class="h-6 border-0 px-0 text-sm shadow-none focus-visible:ring-0"
|
||||
@update:model-value="(value) => (searchText = String(value))"
|
||||
@keydown="handleKeydown"
|
||||
/>
|
||||
<Input ref="searchInput" :model-value="searchText" :placeholder="searchPlaceholder" class="h-6 border-0 px-0 text-sm shadow-none focus-visible:ring-0" @update:model-value="(value) => (searchText = String(value))" @keydown="handleKeydown" />
|
||||
</div>
|
||||
<div ref="listContainer" class="max-h-64 overflow-y-auto py-1">
|
||||
<div v-if="loading" class="px-2 py-2 text-sm text-muted-foreground">
|
||||
|
|
@ -157,12 +139,7 @@ function handleKeydown(event: KeyboardEvent) {
|
|||
v-for="(option, index) in filteredOptions"
|
||||
:key="option"
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none',
|
||||
index === highlightIndex && 'bg-accent text-accent-foreground',
|
||||
)
|
||||
"
|
||||
:class="cn('flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none', index === highlightIndex && 'bg-accent text-accent-foreground')"
|
||||
@click="selectOption(option)"
|
||||
>
|
||||
<Check :class="cn('h-3.5 w-3.5 shrink-0', option === modelValue ? 'opacity-100' : 'opacity-0')" />
|
||||
|
|
@ -190,12 +167,7 @@ function handleKeydown(event: KeyboardEvent) {
|
|||
<button
|
||||
v-else-if="canSelectCustom"
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none',
|
||||
0 === highlightIndex && 'bg-accent text-accent-foreground',
|
||||
)
|
||||
"
|
||||
:class="cn('flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none', 0 === highlightIndex && 'bg-accent text-accent-foreground')"
|
||||
@click="selectCustomOption"
|
||||
>
|
||||
<Check class="h-3.5 w-3.5 shrink-0 opacity-0" />
|
||||
|
|
|
|||
|
|
@ -40,17 +40,13 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
|||
:class="
|
||||
cn(
|
||||
'text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-lg ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 cn-menu-translucent relative z-50 max-h-(--reka-select-content-available-height) origin-(--reka-select-content-transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none',
|
||||
position === 'popper' &&
|
||||
'w-fit min-w-[var(--reka-select-trigger-width)] data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
position === 'popper' && 'w-fit min-w-[var(--reka-select-trigger-width)] data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectViewport
|
||||
:data-position="position"
|
||||
:class="cn('data-[position=popper]:h-[var(--reka-select-trigger-height)] data-[position=popper]:w-full')"
|
||||
>
|
||||
<SelectViewport :data-position="position" :class="cn('data-[position=popper]:h-[var(--reka-select-trigger-height)] data-[position=popper]:w-full')">
|
||||
<slot />
|
||||
</SelectViewport>
|
||||
<SelectScrollDownButton />
|
||||
|
|
|
|||
|
|
@ -15,16 +15,7 @@ const forwardedProps = useForwardProps(delegatedProps);
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<SelectScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*=size-])]:size-4',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<SelectScrollDownButton data-slot="select-scroll-down-button" v-bind="forwardedProps" :class="cn('bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*=size-])]:size-4', props.class)">
|
||||
<slot>
|
||||
<ChevronDownIcon />
|
||||
</slot>
|
||||
|
|
|
|||
|
|
@ -15,16 +15,7 @@ const forwardedProps = useForwardProps(delegatedProps);
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<SelectScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*=size-])]:size-4',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<SelectScrollUpButton data-slot="select-scroll-up-button" v-bind="forwardedProps" :class="cn('bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*=size-])]:size-4', props.class)">
|
||||
<slot>
|
||||
<ChevronUpIcon />
|
||||
</slot>
|
||||
|
|
|
|||
|
|
@ -11,9 +11,5 @@ const delegatedProps = reactiveOmit(props, "class");
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<SelectSeparator
|
||||
data-slot="select-separator"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('bg-border -mx-1 my-1 h-px pointer-events-none', props.class)"
|
||||
/>
|
||||
<SelectSeparator data-slot="select-separator" v-bind="delegatedProps" :class="cn('bg-border -mx-1 my-1 h-px pointer-events-none', props.class)" />
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -7,10 +7,7 @@ import { ChevronDownIcon } from "@lucide/vue";
|
|||
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<SelectTriggerProps & { class?: HTMLAttributes["class"]; size?: "sm" | "default" }>(),
|
||||
{ size: "default" },
|
||||
);
|
||||
const props = withDefaults(defineProps<SelectTriggerProps & { class?: HTMLAttributes["class"]; size?: "sm" | "default" }>(), { size: "default" });
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "size");
|
||||
const forwardedProps = useForwardProps(delegatedProps);
|
||||
|
|
|
|||
|
|
@ -14,14 +14,5 @@ const delegatedProps = reactiveOmit(props, "class");
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<Separator
|
||||
data-slot="separator"
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
/>
|
||||
<Separator data-slot="separator" v-bind="delegatedProps" :class="cn('shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch', props.class)" />
|
||||
</template>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue