feat: initial release

Multi-database management tool built with Tauri + Vue 3.
Supports MySQL, PostgreSQL, SQLite, Redis, MongoDB, DuckDB, ClickHouse, SQL Server.
This commit is contained in:
t8y2
2026-04-29 18:09:23 +08:00
commit b2e6dfad7e
156 changed files with 21979 additions and 0 deletions
+447
View File
@@ -0,0 +1,447 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
import { useI18n } from "vue-i18n";
import { DatabaseZap, FilePlus2, Play, Loader2, X, Globe, Moon, Sun, Upload, Download, Plus, History } from "lucide-vue-next";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import ConnectionTree from "@/components/sidebar/ConnectionTree.vue";
import ConnectionDialog from "@/components/connection/ConnectionDialog.vue";
import QueryEditor from "@/components/editor/QueryEditor.vue";
import DataGrid from "@/components/grid/DataGrid.vue";
import RedisKeyBrowser from "@/components/redis/RedisKeyBrowser.vue";
import AiAssistant from "@/components/editor/AiAssistant.vue";
import MongoDocBrowser from "@/components/mongo/MongoDocBrowser.vue";
import QueryHistory from "@/components/editor/QueryHistory.vue";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useHistoryStore } from "@/stores/historyStore";
import { setLocale, currentLocale, type Locale } from "@/i18n";
import * as api from "@/lib/tauri";
const { t } = useI18n();
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
const historyStore = useHistoryStore();
const showConnectionDialog = ref(false);
const showHistory = ref(false);
const dangerSql = ref("");
const showDangerDialog = ref(false);
const editConfig = computed(() => {
const id = connectionStore.editingConnectionId;
if (!id) return undefined;
return connectionStore.getConfig(id);
});
watch(editConfig, (v) => {
if (v) showConnectionDialog.value = true;
});
watch(showConnectionDialog, (v) => {
if (!v) connectionStore.stopEditing();
});
const activeTab = computed(() =>
queryStore.tabs.find((t) => t.id === queryStore.activeTabId)
);
function onEditorUpdate(val: string) {
if (queryStore.activeTabId) {
queryStore.updateSql(queryStore.activeTabId, val);
}
}
function newQuery() {
if (!connectionStore.activeConnectionId) return;
const conn = connectionStore.connections.find(
(c) => c.id === connectionStore.activeConnectionId
);
if (!conn) return;
queryStore.createTab(conn.id, conn.database || "");
}
const DANGER_RE = /^\s*(DROP|DELETE|TRUNCATE|ALTER)\b/i;
function isDangerousSql(sql: string): boolean {
return DANGER_RE.test(sql);
}
function tryExecute() {
const tab = activeTab.value;
if (!tab || !tab.sql.trim()) return;
if (isDangerousSql(tab.sql)) {
dangerSql.value = tab.sql;
showDangerDialog.value = true;
} else {
doExecute();
}
}
async function doExecute() {
const tab = activeTab.value;
if (!tab) return;
const connName = connectionStore.getConfig(tab.connectionId)?.name || "";
const sql = tab.sql;
const start = Date.now();
await queryStore.executeCurrentTab();
const elapsed = Date.now() - start;
const success = !tab.result?.columns.includes("Error");
historyStore.add({
connection_name: connName,
database: tab.database,
sql,
execution_time_ms: elapsed,
success,
error: success ? undefined : String(tab.result?.rows?.[0]?.[0] ?? ""),
});
}
function onDangerConfirm() {
doExecute();
}
function onHistoryRestore(sql: string) {
if (queryStore.activeTabId) {
queryStore.updateSql(queryStore.activeTabId, sql);
}
}
async function onExecuteSql(sql: string) {
const tab = activeTab.value;
if (!tab) return;
await api.executeQuery(tab.connectionId, tab.database, sql);
}
async function onReloadData() {
const tab = activeTab.value;
if (!tab) return;
if (tab.mode === "data" && tab.tableMeta) {
queryStore.updateSql(tab.id, buildTableSql(tab));
}
queryStore.executeCurrentTab();
}
type ActiveTab = NonNullable<typeof activeTab.value>;
function quoteIdent(tab: ActiveTab, name: string): string {
const config = connectionStore.getConfig(tab.connectionId);
return config?.db_type === "mysql"
? `\`${name.replace(/`/g, "``")}\``
: `"${name.replace(/"/g, '""')}"`;
}
function qualifiedTableName(tab: NonNullable<typeof activeTab.value>): string {
const config = connectionStore.getConfig(tab.connectionId);
if (!tab.tableMeta) return "";
if (config?.db_type === "postgres" && tab.tableMeta.schema) {
return `${quoteIdent(tab, tab.tableMeta.schema)}.${quoteIdent(tab, tab.tableMeta.tableName)}`;
}
return quoteIdent(tab, tab.tableMeta.tableName);
}
function defaultOrderBy(tab: NonNullable<typeof activeTab.value>): string | undefined {
const primaryKeys = tab.tableMeta?.primaryKeys ?? [];
if (primaryKeys.length === 0) return undefined;
return primaryKeys.map((pk) => `${quoteIdent(tab, pk)} ASC`).join(", ");
}
function buildTableSql(
tab: NonNullable<typeof activeTab.value>,
options: { orderBy?: string; limit?: number; offset?: number } = {},
): string {
const limit = options.limit ?? 100;
const orderBy = options.orderBy ?? defaultOrderBy(tab);
const order = orderBy ? ` ORDER BY ${orderBy}` : "";
const offset = options.offset ? ` OFFSET ${options.offset}` : "";
return `SELECT * FROM ${qualifiedTableName(tab)}${order} LIMIT ${limit}${offset};`;
}
async function onPaginate(offset: number, limit: number) {
const tab = activeTab.value;
if (!tab?.tableMeta) return;
const sql = buildTableSql(tab, { limit, offset });
queryStore.updateSql(tab.id, sql);
await queryStore.executeCurrentTab();
}
async function onSort(column: string, direction: "asc" | "desc" | null) {
const tab = activeTab.value;
if (!tab?.tableMeta) return;
const orderBy = direction ? `${quoteIdent(tab, column)} ${direction.toUpperCase()}` : defaultOrderBy(tab);
const sql = buildTableSql(tab, { orderBy });
queryStore.updateSql(tab.id, sql);
await queryStore.executeCurrentTab();
}
function toggleLocale() {
const next: Locale = currentLocale() === "zh-CN" ? "en" : "zh-CN";
setLocale(next);
}
const isDark = ref(localStorage.getItem("dbx-theme") === "dark");
function applyTheme() {
document.documentElement.classList.toggle("dark", isDark.value);
}
function toggleTheme() {
isDark.value = !isDark.value;
localStorage.setItem("dbx-theme", isDark.value ? "dark" : "light");
applyTheme();
}
function handleKeydown(e: KeyboardEvent) {
if (e.metaKey && e.key === "w") {
e.preventDefault();
if (queryStore.activeTabId) {
queryStore.closeTab(queryStore.activeTabId);
}
}
}
onMounted(() => {
applyTheme();
connectionStore.initFromDisk();
window.addEventListener("keydown", handleKeydown);
});
onUnmounted(() => {
window.removeEventListener("keydown", handleKeydown);
});
</script>
<template>
<TooltipProvider :delay-duration="300">
<div class="h-screen w-screen flex flex-col bg-background text-foreground overflow-hidden">
<!-- Toolbar -->
<div class="h-10 flex items-center gap-1 px-2 border-b bg-muted/30 shrink-0">
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="showConnectionDialog = true">
<DatabaseZap class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t('toolbar.newConnection') }}</TooltipContent>
</Tooltip>
<Separator orientation="vertical" class="h-5" />
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="newQuery" :disabled="!connectionStore.activeConnectionId">
<FilePlus2 class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t('toolbar.newQuery') }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:disabled="!activeTab || activeTab.isExecuting"
@click="tryExecute()"
>
<Loader2 v-if="activeTab?.isExecuting" class="h-4 w-4 animate-spin" />
<Play v-else class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t('toolbar.executeShortcut') }}</TooltipContent>
</Tooltip>
<div class="flex-1" />
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-7 w-7" :class="{ 'bg-accent': showHistory }" @click="showHistory = !showHistory">
<History class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t('history.title') }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="toggleTheme">
<Moon v-if="!isDark" class="h-4 w-4" />
<Sun v-else class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ isDark ? 'Light' : 'Dark' }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="toggleLocale">
<Globe class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t('common.language') }}</TooltipContent>
</Tooltip>
<span class="text-xs text-muted-foreground mr-2">DBX</span>
</div>
<!-- Main Content -->
<div class="flex-1 flex min-h-0">
<Splitpanes class="flex-1 min-h-0">
<!-- Sidebar -->
<Pane :size="20" :min-size="10" :max-size="40">
<div class="h-full flex flex-col overflow-hidden">
<div class="h-8 flex items-center px-3 text-xs font-medium text-muted-foreground border-b bg-muted/20">
{{ t('sidebar.connections') }}
<span class="flex-1" />
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="connectionStore.importConnectionsFromFile()">
<Upload class="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t('sidebar.import') }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="connectionStore.exportConnectionsToFile()">
<Download class="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t('sidebar.export') }}</TooltipContent>
</Tooltip>
</div>
<div class="flex-1 overflow-y-auto">
<ConnectionTree />
</div>
</div>
</Pane>
<!-- Editor + Results -->
<Pane :size="80">
<div class="h-full flex flex-col min-w-0">
<!-- Tabs Bar -->
<div v-if="queryStore.tabs.length > 0" class="h-8 flex items-center border-b bg-muted/20 overflow-x-auto shrink-0">
<div
v-for="tab in queryStore.tabs"
:key="tab.id"
class="flex items-center gap-1 px-3 h-full text-xs cursor-pointer border-r hover:bg-accent transition-colors whitespace-nowrap"
:class="{ 'bg-background font-medium': tab.id === queryStore.activeTabId }"
@click="queryStore.activeTabId = tab.id"
>
<span>{{ tab.title }}</span>
<button
class="ml-1 rounded hover:bg-muted-foreground/20 p-0.5"
@click.stop="queryStore.closeTab(tab.id)"
>
<X class="h-3 w-3" />
</button>
</div>
</div>
<!-- Editor Panel -->
<div v-if="activeTab" class="flex flex-col flex-1 min-h-0">
<!-- Query mode: editor + results -->
<template v-if="activeTab.mode === 'query'">
<Splitpanes horizontal class="flex-1">
<Pane :size="40" :min-size="15">
<div class="h-full flex flex-col">
<QueryEditor
class="flex-1"
:model-value="activeTab.sql"
@update:model-value="onEditorUpdate"
@execute="tryExecute()"
/>
<AiAssistant
table-context=""
@insert-sql="(sql: string) => { queryStore.updateSql(activeTab!.id, sql); }"
/>
</div>
</Pane>
<Pane :size="60" :min-size="20">
<div class="h-full">
<DataGrid v-if="activeTab.result" :key="activeTab.id" :result="activeTab.result" :sql="activeTab.sql" />
<div v-else class="h-full flex items-center justify-center text-muted-foreground text-sm">
{{ t('editor.pressToExecute') }}
</div>
</div>
</Pane>
</Splitpanes>
</template>
<!-- Data mode: full-height grid -->
<template v-else-if="activeTab.mode === 'data'">
<div class="flex-1 min-h-0">
<DataGrid
v-if="activeTab.result"
:key="activeTab.id"
:result="activeTab.result"
:sql="activeTab.sql"
:editable="!!activeTab.tableMeta?.primaryKeys?.length"
:table-meta="activeTab.tableMeta"
:on-execute-sql="onExecuteSql"
@reload="onReloadData"
@paginate="onPaginate"
@sort="onSort"
/>
<div v-else-if="activeTab.isExecuting" class="h-full flex items-center justify-center text-muted-foreground text-sm">
<Loader2 class="h-5 w-5 animate-spin mr-2" /> {{ t('common.loading') }}
</div>
</div>
</template>
<!-- Redis mode: key browser -->
<template v-else-if="activeTab.mode === 'redis'">
<div class="flex-1 min-h-0">
<RedisKeyBrowser
:key="activeTab.id"
:connection-id="activeTab.connectionId"
:db="Number(activeTab.database)"
/>
</div>
</template>
<!-- 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"
/>
</div>
</template>
</div>
<!-- Empty State -->
<div v-else class="flex-1 flex items-center justify-center">
<div class="text-center">
<h2 class="text-lg font-medium mb-2">{{ t('welcome.title') }}</h2>
<p class="text-sm text-muted-foreground mb-4">
{{ t('welcome.subtitle') }}
</p>
<Button @click="showConnectionDialog = true">
<Plus class="h-4 w-4 mr-2" /> {{ t('toolbar.newConnection') }}
</Button>
</div>
</div>
</div>
</Pane>
</Splitpanes>
<!-- History Panel (fixed width, outside Splitpanes) -->
<div v-if="showHistory" class="w-72 h-full shrink-0 border-l bg-background">
<QueryHistory @restore="onHistoryRestore" @close="showHistory = false" />
</div>
</div>
<ConnectionDialog v-model:open="showConnectionDialog" :edit-config="editConfig" />
<DangerConfirmDialog v-model:open="showDangerDialog" :sql="dangerSql" @confirm="onDangerConfirm" />
</div>
</TooltipProvider>
</template>
@@ -0,0 +1,240 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import type { ConnectionConfig, DatabaseType } from "@/types/database";
import { useConnectionStore } from "@/stores/connectionStore";
import * as api from "@/lib/tauri";
const { t } = useI18n();
const open = defineModel<boolean>("open", { default: false });
const props = defineProps<{
editConfig?: ConnectionConfig;
}>();
const store = useConnectionStore();
const isTesting = ref(false);
const testResult = ref<{ ok: boolean; message: string } | null>(null);
const editingId = ref<string | null>(null);
const defaultForm = (): Omit<ConnectionConfig, "id"> => ({
name: "",
db_type: "mysql",
host: "127.0.0.1",
port: 3306,
username: "root",
password: "",
database: undefined,
ssh_enabled: false,
ssh_host: "",
ssh_port: 22,
ssh_user: "",
ssh_key_path: "",
});
const form = ref(defaultForm());
watch(() => props.editConfig, (config) => {
if (config) {
editingId.value = config.id;
form.value = {
name: config.name,
db_type: config.db_type,
host: config.host,
port: config.port,
username: config.username,
password: config.password,
database: config.database,
ssh_enabled: config.ssh_enabled || false,
ssh_host: config.ssh_host || "",
ssh_port: config.ssh_port || 22,
ssh_user: config.ssh_user || "",
ssh_key_path: config.ssh_key_path || "",
};
} else {
editingId.value = null;
form.value = defaultForm();
}
testResult.value = null;
});
const isEditing = ref(false);
watch(() => editingId.value, (v) => { isEditing.value = !!v; });
function onDbTypeChange(val: string) {
form.value.db_type = val as DatabaseType;
if (!editingId.value) {
if (val === "mysql") { form.value.port = 3306; form.value.username = "root"; }
else if (val === "postgres") { form.value.port = 5432; form.value.username = "postgres"; }
else if (val === "redis") { form.value.port = 6379; form.value.username = ""; }
else if (val === "sqlite" || val === "duckdb") { form.value.port = 0; form.value.username = ""; }
else if (val === "mongodb") { form.value.port = 27017; form.value.username = ""; }
else if (val === "clickhouse") { form.value.port = 8123; form.value.username = "default"; }
else if (val === "sqlserver") { form.value.port = 1433; form.value.username = "sa"; }
}
}
async function testConnection() {
isTesting.value = true;
testResult.value = null;
try {
const config: ConnectionConfig = { ...form.value, id: editingId.value || crypto.randomUUID() };
const msg = await api.testConnection(config);
testResult.value = { ok: true, message: msg };
} catch (e: any) {
testResult.value = { ok: false, message: String(e) };
} finally {
isTesting.value = false;
}
}
async function save() {
if (editingId.value) {
const updated: ConnectionConfig = { ...form.value, id: editingId.value };
store.updateConnection(updated);
open.value = false;
} else {
const config: ConnectionConfig = { ...form.value, id: crypto.randomUUID() };
store.addConnection(config);
await store.connect(config);
open.value = false;
}
editingId.value = null;
form.value = defaultForm();
testResult.value = null;
}
const dialogTitle = ref("");
watch([() => editingId.value, () => open.value], () => {
dialogTitle.value = editingId.value ? t('connection.editTitle') : t('connection.title');
});
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>{{ editingId ? t('connection.editTitle') : t('connection.title') }}</DialogTitle>
</DialogHeader>
<div class="grid gap-4 py-4">
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t('connection.name') }}</Label>
<Input v-model="form.name" class="col-span-3" :placeholder="t('connection.namePlaceholder')" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t('connection.type') }}</Label>
<Select :model-value="form.db_type" @update:model-value="(val: any) => onDbTypeChange(String(val))">
<SelectTrigger class="col-span-3">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="mysql">MySQL</SelectItem>
<SelectItem value="postgres">PostgreSQL</SelectItem>
<SelectItem value="sqlite">SQLite</SelectItem>
<SelectItem value="redis">Redis</SelectItem>
<SelectItem value="mongodb">MongoDB</SelectItem>
<SelectItem value="duckdb">DuckDB</SelectItem>
<SelectItem value="clickhouse">ClickHouse</SelectItem>
<SelectItem value="sqlserver">SQL Server</SelectItem>
</SelectContent>
</Select>
</div>
<!-- SQLite / DuckDB: file path only -->
<template v-if="form.db_type === 'sqlite' || form.db_type === 'duckdb'">
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t('connection.filePath') }}</Label>
<Input v-model="form.host" class="col-span-3" placeholder="/path/to/database.db" />
</div>
</template>
<!-- Redis: host, port, password -->
<template v-else-if="form.db_type === 'redis'">
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t('connection.host') }}</Label>
<Input v-model="form.host" class="col-span-2" />
<Input v-model.number="form.port" type="number" class="col-span-1" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t('connection.password') }}</Label>
<Input v-model="form.password" type="password" class="col-span-3" :placeholder="t('connection.databasePlaceholder')" />
</div>
</template>
<!-- MySQL / PostgreSQL: host, port, user, password, database -->
<template v-else>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t('connection.host') }}</Label>
<Input v-model="form.host" class="col-span-2" />
<Input v-model.number="form.port" type="number" class="col-span-1" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t('connection.user') }}</Label>
<Input v-model="form.username" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t('connection.password') }}</Label>
<Input v-model="form.password" type="password" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t('connection.database') }}</Label>
<Input v-model="form.database" class="col-span-3" :placeholder="t('connection.databasePlaceholder')" />
</div>
</template>
<!-- SSH Tunnel (not for SQLite) -->
<template v-if="form.db_type !== 'sqlite'">
<div class="grid grid-cols-4 items-center gap-4 pt-2 border-t">
<Label class="text-right text-xs">{{ t('connection.sshTunnel') }}</Label>
<div class="col-span-3">
<input type="checkbox" v-model="form.ssh_enabled" class="mr-2" />
<span class="text-xs text-muted-foreground">{{ t('connection.sshEnable') }}</span>
</div>
</div>
<template v-if="form.ssh_enabled">
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right text-xs">{{ t('connection.sshHost') }}</Label>
<Input v-model="form.ssh_host" class="col-span-2" placeholder="ssh.example.com" />
<Input v-model.number="form.ssh_port" type="number" class="col-span-1" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right text-xs">{{ t('connection.sshUser') }}</Label>
<Input v-model="form.ssh_user" class="col-span-3" placeholder="root" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right text-xs">{{ t('connection.sshKeyPath') }}</Label>
<Input v-model="form.ssh_key_path" class="col-span-3" placeholder="~/.ssh/id_rsa" />
</div>
</template>
</template>
</div>
<DialogFooter class="flex items-center gap-2">
<span v-if="testResult" :class="testResult.ok ? 'text-green-500' : 'text-red-500'" class="text-sm mr-auto">
{{ testResult.ok ? t('connection.testSuccess') : testResult.message }}
</span>
<Button variant="outline" :disabled="isTesting" @click="testConnection">
{{ isTesting ? t('connection.testing') : t('connection.test') }}
</Button>
<Button @click="save" :disabled="!form.name || !form.host">
{{ editingId ? t('connection.save') : t('connection.saveAndConnect') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
+132
View File
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { ref } from "vue";
import { useI18n } from "vue-i18n";
import { Sparkles, Loader2, Settings } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from "@/components/ui/dialog";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { useSettingsStore, type AiProvider } from "@/stores/settingsStore";
import { generateSql } from "@/lib/ai";
const { t } = useI18n();
const settings = useSettingsStore();
const props = defineProps<{
tableContext: string;
}>();
const emit = defineEmits<{
insertSql: [sql: string];
}>();
const prompt = ref("");
const isGenerating = ref(false);
const showSettings = ref(false);
const error = ref("");
const tempProvider = ref<AiProvider>(settings.aiConfig.provider);
const tempApiKey = ref(settings.aiConfig.apiKey);
const tempEndpoint = ref(settings.aiConfig.endpoint);
const tempModel = ref(settings.aiConfig.model);
function openSettings() {
tempProvider.value = settings.aiConfig.provider;
tempApiKey.value = settings.aiConfig.apiKey;
tempEndpoint.value = settings.aiConfig.endpoint;
tempModel.value = settings.aiConfig.model;
showSettings.value = true;
}
function saveSettings() {
settings.updateAiConfig({
provider: tempProvider.value,
apiKey: tempApiKey.value,
endpoint: tempEndpoint.value,
model: tempModel.value,
});
showSettings.value = false;
}
async function generate() {
if (!prompt.value.trim()) return;
if (!settings.isConfigured()) {
openSettings();
return;
}
isGenerating.value = true;
error.value = "";
try {
const sql = await generateSql(settings.aiConfig, prompt.value, props.tableContext);
emit("insertSql", sql.trim());
prompt.value = "";
} catch (e: any) {
error.value = String(e.message || e);
} finally {
isGenerating.value = false;
}
}
</script>
<template>
<div class="flex items-center gap-1 px-2 py-1 border-t bg-muted/20">
<Sparkles class="w-3.5 h-3.5 text-purple-500 shrink-0" />
<Input
v-model="prompt"
class="h-6 text-xs flex-1 border-0 shadow-none focus-visible:ring-0"
:placeholder="t('ai.placeholder')"
@keydown.enter="generate"
/>
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" :disabled="isGenerating" @click="generate">
<Loader2 v-if="isGenerating" class="h-3 w-3 animate-spin" />
<Sparkles v-else class="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" @click="openSettings">
<Settings class="h-3 w-3" />
</Button>
<span v-if="error" class="text-destructive text-xs truncate max-w-40">{{ error }}</span>
</div>
<!-- Settings Dialog -->
<Dialog v-model:open="showSettings">
<DialogContent class="sm:max-w-96">
<DialogHeader>
<DialogTitle>{{ t('ai.settings') }}</DialogTitle>
</DialogHeader>
<div class="grid gap-3 py-3">
<div class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">{{ t('ai.provider') }}</Label>
<Select :model-value="tempProvider" @update:model-value="(v: any) => tempProvider = v">
<SelectTrigger class="col-span-2 h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="claude">Claude</SelectItem>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
</div>
<div class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">API Key</Label>
<Input v-model="tempApiKey" type="password" class="col-span-2 h-8 text-xs" />
</div>
<div class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">Endpoint</Label>
<Input v-model="tempEndpoint" class="col-span-2 h-8 text-xs" />
</div>
<div class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">Model</Label>
<Input v-model="tempModel" class="col-span-2 h-8 text-xs" />
</div>
</div>
<DialogFooter>
<Button size="sm" @click="saveSettings">{{ t('grid.save') }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { AlertTriangle } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from "@/components/ui/dialog";
const { t } = useI18n();
const open = defineModel<boolean>("open", { default: false });
defineProps<{
sql: string;
}>();
const emit = defineEmits<{
confirm: [];
}>();
function onConfirm() {
open.value = false;
emit("confirm");
}
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle class="flex items-center gap-2 text-destructive">
<AlertTriangle class="h-5 w-5" />
{{ t('dangerDialog.title') }}
</DialogTitle>
</DialogHeader>
<div class="py-4">
<p class="text-sm text-muted-foreground mb-3">{{ t('dangerDialog.message') }}</p>
<pre class="text-xs bg-muted p-3 rounded overflow-auto max-h-40 font-mono whitespace-pre-wrap">{{ sql }}</pre>
</div>
<DialogFooter>
<Button variant="outline" @click="open = false">{{ t('dangerDialog.cancel') }}</Button>
<Button variant="destructive" @click="onConfirm">{{ t('dangerDialog.confirm') }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
+88
View File
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, shallowRef } from "vue";
import type { EditorView as EditorViewType } from "@codemirror/view";
const props = defineProps<{
modelValue: string;
dialect?: "mysql" | "postgres";
}>();
const emit = defineEmits<{
"update:modelValue": [value: string];
execute: [];
}>();
const editorRef = ref<HTMLDivElement>();
const view = shallowRef<EditorViewType | null>(null);
onMounted(async () => {
if (!editorRef.value) return;
const [
{ EditorView, keymap },
{ EditorState },
{ sql, MySQL, PostgreSQL },
{ basicSetup },
{ oneDark },
] = await Promise.all([
import("@codemirror/view"),
import("@codemirror/state"),
import("@codemirror/lang-sql"),
import("codemirror"),
import("@codemirror/theme-one-dark"),
]);
const dialect = props.dialect === "postgres" ? PostgreSQL : MySQL;
const runKeymap = keymap.of([
{
key: "Mod-Enter",
run: () => {
emit("execute");
return true;
},
},
]);
const state = EditorState.create({
doc: props.modelValue,
extensions: [
basicSetup,
sql({ dialect }),
oneDark,
runKeymap,
EditorView.updateListener.of((update) => {
if (update.docChanged) {
emit("update:modelValue", update.state.doc.toString());
}
}),
EditorView.theme({
"&": { height: "100%", fontSize: "13px" },
".cm-scroller": { overflow: "auto" },
".cm-content": { fontFamily: "'JetBrains Mono', 'Fira Code', monospace" },
}),
],
});
view.value = new EditorView({ state, parent: editorRef.value });
});
watch(
() => props.modelValue,
(val) => {
if (view.value && val !== view.value.state.doc.toString()) {
view.value.dispatch({
changes: { from: 0, to: view.value.state.doc.length, insert: val },
});
}
}
);
onBeforeUnmount(() => {
view.value?.destroy();
});
</script>
<template>
<div ref="editorRef" class="h-full w-full overflow-hidden" />
</template>
+106
View File
@@ -0,0 +1,106 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import { Search, Trash2, Clock, X } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import {
ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { useHistoryStore } from "@/stores/historyStore";
const { t } = useI18n();
const store = useHistoryStore();
const emit = defineEmits<{
restore: [sql: string];
close: [];
}>();
const searchText = ref("");
const filtered = computed(() => {
if (!searchText.value) return store.entries;
const q = searchText.value.toLowerCase();
return store.entries.filter((e) =>
e.sql.toLowerCase().includes(q) || e.connection_name.toLowerCase().includes(q) || e.database.toLowerCase().includes(q)
);
});
function restore(sql: string) {
emit("restore", sql);
}
function copySql(sql: string) {
navigator.clipboard.writeText(sql);
}
function formatTime(iso: string): string {
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, "0");
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function truncateSql(sql: string): string {
const line = sql.replace(/\s+/g, " ").trim();
return line.length > 120 ? line.slice(0, 120) + "..." : line;
}
onMounted(() => store.load());
</script>
<template>
<div class="h-full flex flex-col overflow-hidden border-l">
<div class="flex items-center gap-1 px-2 py-1.5 border-b shrink-0 bg-muted/20">
<Clock class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<span class="text-xs font-medium">{{ t('history.title') }}</span>
<span class="flex-1" />
<Button v-if="store.entries.length > 0" variant="ghost" size="icon" class="h-5 w-5" @click="store.clear()">
<Trash2 class="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="emit('close')">
<X class="h-3 w-3" />
</Button>
</div>
<div class="flex items-center gap-1 px-2 py-1 border-b shrink-0">
<Search class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<input
v-model="searchText"
class="flex-1 h-5 text-xs bg-transparent outline-none placeholder:text-muted-foreground"
:placeholder="t('history.search')"
/>
</div>
<div class="flex-1 overflow-y-auto">
<ContextMenu v-for="entry in filtered" :key="entry.id">
<ContextMenuTrigger as-child>
<div
class="px-3 py-2 border-b border-border/50 cursor-pointer hover:bg-accent/50 text-xs"
@click="restore(entry.sql)"
>
<div class="flex items-center gap-1 mb-0.5">
<span class="font-medium truncate">{{ entry.connection_name }}</span>
<span v-if="entry.database" class="text-muted-foreground">/ {{ entry.database }}</span>
<span class="ml-auto text-muted-foreground shrink-0">{{ formatTime(entry.executed_at) }}</span>
</div>
<div class="font-mono text-muted-foreground truncate">{{ truncateSql(entry.sql) }}</div>
<div class="flex items-center gap-2 mt-0.5">
<span :class="entry.success ? 'text-green-500' : 'text-red-500'">
{{ entry.success ? `${entry.execution_time_ms}ms` : t('history.failed') }}
</span>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent class="w-40">
<ContextMenuItem @click="restore(entry.sql)">{{ t('history.restore') }}</ContextMenuItem>
<ContextMenuItem @click="copySql(entry.sql)">{{ t('history.copy') }}</ContextMenuItem>
<ContextMenuItem class="text-destructive" @click="store.remove(entry.id)">{{ t('history.delete') }}</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<div v-if="filtered.length === 0" class="px-3 py-8 text-center text-muted-foreground text-xs">
{{ t('history.empty') }}
</div>
</div>
</div>
</template>
+638
View File
@@ -0,0 +1,638 @@
<script setup lang="ts">
import { ref, computed, nextTick, watch } from "vue";
import { useElementSize } from "@vueuse/core";
import { useI18n } from "vue-i18n";
import { ArrowUp, ArrowDown, Download, Plus, Trash2, Save, ChevronLeft, ChevronRight, Search, Inbox, SearchX } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import {
ContextMenu, ContextMenuContent, ContextMenuItem,
ContextMenuSeparator, ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { QueryResult, ColumnInfo } from "@/types/database";
import { save as savePath } from "@tauri-apps/plugin-dialog";
import { writeTextFile } from "@tauri-apps/plugin-fs";
const { t } = useI18n();
const props = defineProps<{
result: QueryResult;
sql?: string;
editable?: boolean;
tableMeta?: {
schema?: string;
tableName: string;
columns: ColumnInfo[];
primaryKeys: string[];
};
onExecuteSql?: (sql: string) => Promise<void>;
}>();
const emit = defineEmits<{
reload: [];
paginate: [offset: number, limit: number];
sort: [column: string, direction: "asc" | "desc" | null];
}>();
const hasData = computed(() => props.result.columns.length > 0);
const contextCell = ref<{ row: number; col: number } | null>(null);
const sortCol = ref<string | null>(null);
const sortDir = ref<"asc" | "desc">("asc");
const searchText = ref("");
const columnWidths = ref<number[]>([]);
const gridRef = ref<HTMLDivElement>();
const headerRef = ref<HTMLDivElement>();
const { width: gridWidth } = useElementSize(gridRef);
function initColumnWidths() {
if (columnWidths.value.length !== props.result.columns.length) {
columnWidths.value = props.result.columns.map(() => 150);
}
}
function syncHeaderScroll(e: Event) {
if (headerRef.value) {
headerRef.value.scrollLeft = (e.target as HTMLElement).scrollLeft;
}
}
let isResizing = false;
function onResizeStart(colIdx: number, event: MouseEvent) {
event.preventDefault();
isResizing = true;
const startX = event.clientX;
const startWidth = columnWidths.value[colIdx];
const onMove = (e: MouseEvent) => {
columnWidths.value[colIdx] = Math.max(60, startWidth + e.clientX - startX);
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
requestAnimationFrame(() => { isResizing = false; });
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
}
const baseTotalWidth = computed(() => columnWidths.value.reduce((a, b) => a + b, 0));
const renderedColumnWidths = computed(() => {
const widths = columnWidths.value;
if (widths.length === 0) return widths;
const extraWidth = Math.max(0, gridWidth.value - baseTotalWidth.value);
if (extraWidth === 0) return widths;
const extraPerColumn = extraWidth / widths.length;
return widths.map((width) => width + extraPerColumn);
});
const totalWidth = computed(() => renderedColumnWidths.value.reduce((a, b) => a + b, 0));
const columnVars = computed(() => {
const vars: Record<string, string> = {};
renderedColumnWidths.value.forEach((w, i) => {
vars[`--col-w-${i}`] = `${w}px`;
});
vars['--total-w'] = `${totalWidth.value}px`;
return vars;
});
initColumnWidths();
watch(() => props.result.columns.length, initColumnWidths);
// --- Pagination ---
const pageSize = ref(100);
const currentPage = ref(1);
const isFullPage = computed(() => props.result.rows.length >= pageSize.value);
function prevPage() {
if (currentPage.value <= 1) return;
currentPage.value--;
emit("paginate", (currentPage.value - 1) * pageSize.value, pageSize.value);
}
function nextPage() {
if (!isFullPage.value) return;
currentPage.value++;
emit("paginate", (currentPage.value - 1) * pageSize.value, pageSize.value);
}
function changePageSize(size: number) {
pageSize.value = size;
currentPage.value = 1;
emit("paginate", 0, size);
}
// --- Editing ---
type CellValue = string | number | boolean | null;
const editingCell = ref<{ row: number; col: number } | null>(null);
const editValue = ref("");
const scrollerRef = ref<HTMLElement | { $el?: HTMLElement; el?: HTMLElement | { value?: HTMLElement } } | null>(null);
const dirtyRows = ref<Map<number, Map<number, CellValue>>>(new Map());
const newRows = ref<CellValue[][]>([]);
const deletedRows = ref<Set<number>>(new Set());
const hasPendingChanges = computed(() =>
dirtyRows.value.size > 0 || newRows.value.length > 0 || deletedRows.value.size > 0
);
const sortedRows = computed(() => {
let rows = props.result.rows;
if (searchText.value) {
const q = searchText.value.toLowerCase();
rows = rows.filter((row) => row.some((cell) => cell !== null && String(cell).toLowerCase().includes(q)));
}
return rows;
});
interface RowItem {
id: number;
data: CellValue[];
isNew: boolean;
isDeleted: boolean;
isDirtyCol: boolean[];
}
const displayItems = computed<RowItem[]>(() => {
const cols = props.result.columns;
const items: RowItem[] = sortedRows.value.map((row, i) => {
const dirty = dirtyRows.value.get(i);
const data = row.map((v, colIdx) => dirty?.get(colIdx) ?? v);
const isDirtyCol = row.map((_, colIdx) => dirty?.has(colIdx) ?? false);
return { id: i, data, isNew: false, isDeleted: deletedRows.value.has(i), isDirtyCol };
});
newRows.value.forEach((row, i) => {
items.push({ id: 100000 + i, data: row, isNew: true, isDeleted: false, isDirtyCol: cols.map(() => false) });
});
return items;
});
const hasVisibleRows = computed(() => displayItems.value.length > 0);
const emptyTitle = computed(() => searchText.value ? t('grid.noSearchResults') : t('grid.noRows'));
const emptyDescription = computed(() => searchText.value ? t('grid.noSearchResultsDescription') : t('grid.noRowsDescription'));
function toggleSort(colName: string) {
if (isResizing) return;
if (sortCol.value === colName) {
if (sortDir.value === "asc") { sortDir.value = "desc"; emit("sort", colName, "desc"); }
else { sortCol.value = null; sortDir.value = "asc"; emit("sort", colName, null); }
} else {
sortCol.value = colName;
sortDir.value = "asc";
emit("sort", colName, "asc");
}
}
function formatCell(value: CellValue): string {
if (value === null) return "NULL";
if (typeof value === "boolean") return value ? "true" : "false";
return String(value);
}
function isNull(value: unknown): boolean { return value === null; }
// --- Inline editor ---
let isCancelling = false;
let cancelScrollRestoreFrame = 0;
function getScrollerElement(): HTMLElement | null {
const scroller = scrollerRef.value;
if (!scroller) return null;
if (scroller instanceof HTMLElement) return scroller;
if (scroller.$el instanceof HTMLElement) return scroller.$el;
if (scroller.el instanceof HTMLElement) return scroller.el;
if (scroller.el?.value instanceof HTMLElement) return scroller.el.value;
return null;
}
function preserveScrollPosition() {
const el = getScrollerElement();
if (!el) return () => {};
const top = el.scrollTop;
const left = el.scrollLeft;
return () => {
el.scrollTop = top;
el.scrollLeft = left;
};
}
function focusScrollerWithoutScrolling() {
const el = getScrollerElement();
if (!el) return;
if (!el.hasAttribute("tabindex")) el.setAttribute("tabindex", "-1");
el.focus({ preventScroll: true });
}
function restoreScrollAcrossFrames(restoreScroll: () => void) {
if (cancelScrollRestoreFrame) cancelAnimationFrame(cancelScrollRestoreFrame);
restoreScroll();
nextTick(() => {
restoreScroll();
cancelScrollRestoreFrame = requestAnimationFrame(() => {
restoreScroll();
cancelScrollRestoreFrame = requestAnimationFrame(() => {
restoreScroll();
cancelScrollRestoreFrame = 0;
isCancelling = false;
});
});
});
}
function startEdit(rowIdx: number, colIdx: number) {
if (!props.editable) return;
isCancelling = false;
editingCell.value = { row: rowIdx, col: colIdx };
const item = displayItems.value.find((it) => it.id === rowIdx);
const val = item?.data[colIdx] ?? null;
editValue.value = val === null ? "" : String(val);
nextTick(() => {
const input = document.querySelector(".cell-edit-input") as HTMLInputElement;
input?.focus();
input?.select();
});
}
function commitEdit() {
if (isCancelling) return;
if (!editingCell.value) return;
const { row, col } = editingCell.value;
const oldVal = sortedRows.value[row]?.[col];
let newVal: CellValue = editValue.value;
if (newVal === "" && isNull(oldVal)) newVal = null;
else if (newVal === "NULL") newVal = null;
else if (typeof oldVal === "number") {
const num = Number(newVal);
if (!isNaN(num)) newVal = num;
} else if (typeof oldVal === "boolean") {
newVal = newVal === "true" || newVal === "1";
}
if (newVal !== oldVal) {
if (!dirtyRows.value.has(row)) dirtyRows.value.set(row, new Map());
dirtyRows.value.get(row)!.set(col, newVal);
}
editingCell.value = null;
}
function cancelEdit() {
const restoreScroll = preserveScrollPosition();
isCancelling = true;
focusScrollerWithoutScrolling();
editingCell.value = null;
restoreScrollAcrossFrames(restoreScroll);
}
function onEditKeydown(e: KeyboardEvent) {
if (e.key === "Enter") { e.preventDefault(); commitEdit(); }
else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); cancelEdit(); }
}
function addRow() {
newRows.value.push(props.result.columns.map(() => null));
}
function deleteSelectedRow() {
if (!contextCell.value) return;
deletedRows.value.add(contextCell.value.row);
}
function escapeVal(v: CellValue): string {
if (v === null) return "NULL";
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
if (typeof v === "number") return String(v);
return `'${String(v).replace(/'/g, "''")}'`;
}
function qualifiedTableName(): string {
if (!props.tableMeta) return "";
const { schema, tableName } = props.tableMeta;
return schema ? `"${schema}"."${tableName}"` : `"${tableName}"`;
}
function generateSaveStatements(): string[] {
if (!props.tableMeta) return [];
const { primaryKeys } = props.tableMeta;
const cols = props.result.columns;
const stmts: string[] = [];
const tbl = qualifiedTableName();
for (const [rowIdx, changes] of dirtyRows.value) {
const row = sortedRows.value[rowIdx];
if (!row) continue;
const sets = Array.from(changes.entries())
.map(([colIdx, val]) => `"${cols[colIdx]}" = ${escapeVal(val)}`)
.join(", ");
const where = primaryKeys
.map((pk) => `"${pk}" = ${escapeVal(row[cols.indexOf(pk)])}`)
.join(" AND ");
stmts.push(`UPDATE ${tbl} SET ${sets} WHERE ${where};`);
}
for (const rowIdx of deletedRows.value) {
const row = sortedRows.value[rowIdx];
if (!row) continue;
const where = primaryKeys
.map((pk) => `"${pk}" = ${escapeVal(row[cols.indexOf(pk)])}`)
.join(" AND ");
stmts.push(`DELETE FROM ${tbl} WHERE ${where};`);
}
for (const newRow of newRows.value) {
const colNames = cols.map((c) => `"${c}"`).join(", ");
const vals = newRow.map((v) => escapeVal(v)).join(", ");
stmts.push(`INSERT INTO ${tbl} (${colNames}) VALUES (${vals});`);
}
return stmts;
}
const saveError = ref("");
async function saveChanges() {
const stmts = generateSaveStatements();
if (stmts.length === 0) return;
saveError.value = "";
if (props.onExecuteSql) {
try {
for (const sql of stmts) {
await props.onExecuteSql(sql);
}
} catch (e: any) {
saveError.value = String(e.message || e);
return;
}
}
dirtyRows.value.clear();
newRows.value = [];
deletedRows.value.clear();
emit("reload");
}
function discardChanges() {
dirtyRows.value.clear();
newRows.value = [];
deletedRows.value.clear();
editingCell.value = null;
}
// --- Copy/Export ---
function onCellContext(rowIdx: number, colIdx: number) {
contextCell.value = { row: rowIdx, col: colIdx };
}
function copyCell() {
if (!contextCell.value) return;
const item = displayItems.value.find((it) => it.id === contextCell.value!.row);
const val = item?.data[contextCell.value.col] ?? null;
navigator.clipboard.writeText(formatCell(val));
}
function copyRow() {
if (!contextCell.value) return;
const row = sortedRows.value[contextCell.value.row];
if (!row) return;
const obj: Record<string, unknown> = {};
props.result.columns.forEach((col, i) => { obj[col] = row[i]; });
navigator.clipboard.writeText(JSON.stringify(obj, null, 2));
}
function copyAll() {
const header = props.result.columns.join("\t");
const body = sortedRows.value.map((row) => row.map((c) => formatCell(c)).join("\t")).join("\n");
navigator.clipboard.writeText(`${header}\n${body}`);
}
async function exportCsv() {
const escape = (v: string) => `"${v.replace(/"/g, '""')}"`;
const header = props.result.columns.map(escape).join(",");
const body = sortedRows.value.map((row) => row.map((c) => escape(formatCell(c))).join(",")).join("\n");
const path = await savePath({ filters: [{ name: "CSV", extensions: ["csv"] }] });
if (path) await writeTextFile(path, `${header}\n${body}`);
}
async function exportJson() {
const data = sortedRows.value.map((row) => {
const obj: Record<string, unknown> = {};
props.result.columns.forEach((col, i) => { obj[col] = row[i]; });
return obj;
});
const path = await savePath({ filters: [{ name: "JSON", extensions: ["json"] }] });
if (path) await writeTextFile(path, JSON.stringify(data, null, 2));
}
async function exportMarkdown() {
const pad = (s: string, len: number) => s.padEnd(len);
const cols = props.result.columns;
const widths = cols.map((c, i) => Math.max(c.length, ...sortedRows.value.map((r) => formatCell(r[i]).length), 3));
const header = `| ${cols.map((c, i) => pad(c, widths[i])).join(" | ")} |`;
const sep = `| ${widths.map((w) => "-".repeat(w)).join(" | ")} |`;
const body = sortedRows.value.map((row) =>
`| ${row.map((c, i) => pad(formatCell(c), widths[i])).join(" | ")} |`
).join("\n");
const md = `${header}\n${sep}\n${body}\n`;
const path = await savePath({ filters: [{ name: "Markdown", extensions: ["md"] }] });
if (path) await writeTextFile(path, md);
}
const sqlOneLiner = computed(() => props.sql?.replace(/\s+/g, " ").trim() || "");
</script>
<template>
<div ref="gridRef" class="h-full flex flex-col overflow-hidden" :style="columnVars">
<ContextMenu>
<ContextMenuTrigger as-child>
<div v-if="hasData" class="flex-1 flex flex-col overflow-hidden">
<!-- Search bar -->
<div class="flex items-center gap-1 px-2 py-1 border-b shrink-0 bg-muted/20">
<Search class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<input
v-model="searchText"
class="flex-1 h-5 text-xs bg-transparent outline-none placeholder:text-muted-foreground"
:placeholder="t('grid.search')"
/>
<span v-if="searchText" class="text-xs text-muted-foreground">
{{ sortedRows.length }}/{{ result.rows.length }}
</span>
</div>
<!-- Sticky header -->
<div ref="headerRef" class="shrink-0 bg-muted z-10 border-b border-border overflow-hidden">
<div class="flex text-xs font-medium" :style="{ width: 'var(--total-w)' }">
<div
v-for="(col, colIdx) in result.columns"
:key="col"
class="shrink-0 px-3 py-1.5 border-r border-border whitespace-nowrap cursor-pointer hover:bg-accent/50 select-none relative"
:style="{ width: `var(--col-w-${colIdx})` }"
@click="toggleSort(col)"
>
<span class="inline-flex items-center gap-1">
{{ col }}
<ArrowUp v-if="sortCol === col && sortDir === 'asc'" class="w-3 h-3" />
<ArrowDown v-else-if="sortCol === col && sortDir === 'desc'" class="w-3 h-3" />
</span>
<div
class="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-primary/30"
@mousedown.stop="onResizeStart(colIdx, $event)"
/>
</div>
</div>
</div>
<div
v-if="!hasVisibleRows"
class="flex-1 flex flex-col items-center justify-center gap-2 px-6 text-center text-muted-foreground"
>
<component
:is="searchText ? SearchX : Inbox"
class="h-8 w-8 text-muted-foreground/50"
aria-hidden="true"
/>
<div class="space-y-1">
<div class="text-sm font-medium text-foreground">{{ emptyTitle }}</div>
<div class="text-xs">{{ emptyDescription }}</div>
</div>
</div>
<!-- Virtual scrolled rows -->
<RecycleScroller
v-else
ref="scrollerRef"
class="data-grid-scroller flex-1 overflow-x-auto"
:items="displayItems"
:item-size="26"
key-field="id"
@scroll="syncHeaderScroll"
>
<template #default="{ item }">
<div
class="flex text-xs border-b border-border hover:bg-accent/50"
:class="{
'line-through opacity-30': item.isDeleted,
'bg-green-500/10': item.isNew,
}"
:style="{ height: '26px', width: 'var(--total-w)' }"
>
<div
v-for="(cell, colIdx) in item.data"
:key="colIdx"
class="shrink-0 px-3 py-1 border-r border-border whitespace-nowrap overflow-hidden text-ellipsis relative"
:style="{ width: `var(--col-w-${colIdx})` }"
:class="{
'text-muted-foreground italic': isNull(cell),
'bg-yellow-500/10': item.isDirtyCol[colIdx],
}"
@dblclick="!item.isNew && !item.isDeleted && startEdit(item.id, colIdx)"
@contextmenu="onCellContext(item.id, colIdx)"
>
<template v-if="editingCell?.row === item.id && editingCell?.col === colIdx">
<input
v-model="editValue"
class="cell-edit-input absolute inset-0 bg-background border-2 border-primary px-2 py-0.5 text-xs outline-none z-10"
@blur="commitEdit"
@keydown.stop="onEditKeydown"
/>
</template>
<template v-else>
{{ formatCell(cell) }}
</template>
</div>
</div>
</template>
</RecycleScroller>
</div>
</ContextMenuTrigger>
<ContextMenuContent class="w-48">
<ContextMenuItem @click="copyCell">{{ t('grid.copyCell') }}</ContextMenuItem>
<ContextMenuItem @click="copyRow">{{ t('grid.copyRow') }}</ContextMenuItem>
<ContextMenuItem @click="copyAll">{{ t('grid.copyAll') }}</ContextMenuItem>
<ContextMenuSeparator />
<template v-if="editable">
<ContextMenuItem class="text-destructive" @click="deleteSelectedRow">
<Trash2 class="w-3.5 h-3.5 mr-2" /> {{ t('grid.deleteRow') }}
</ContextMenuItem>
<ContextMenuSeparator />
</template>
<ContextMenuItem @click="exportCsv">{{ t('grid.exportCsv') }}</ContextMenuItem>
<ContextMenuItem @click="exportJson">{{ t('grid.exportJson') }}</ContextMenuItem>
<ContextMenuItem @click="exportMarkdown">{{ t('grid.exportMarkdown') }}</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<div v-if="!hasData" class="flex-1 flex items-center justify-center text-muted-foreground text-sm">
{{ t('grid.querySuccess') }}
</div>
<!-- Error bar -->
<div v-if="saveError" class="px-3 py-1.5 border-t bg-destructive/10 text-destructive text-xs shrink-0 flex items-center gap-2">
<span class="flex-1">{{ saveError }}</span>
<button class="hover:underline" @click="saveError = ''">dismiss</button>
</div>
<!-- Bottom status bar -->
<div class="flex items-center gap-2 px-3 py-1 border-t text-xs text-muted-foreground bg-muted/30 shrink-0">
<span v-if="hasData">{{ t('grid.rows', { count: result.rows.length }) }}</span>
<span v-else>{{ t('grid.rowsAffected', { count: result.affected_rows }) }}</span>
<span>{{ result.execution_time_ms }}ms</span>
<template v-if="editable && tableMeta">
<Button v-if="hasPendingChanges" variant="default" size="sm" class="h-5 text-xs ml-2" @click="saveChanges">
<Save class="w-3 h-3 mr-1" /> {{ t('grid.save') }}
</Button>
<Button v-if="hasPendingChanges" variant="ghost" size="sm" class="h-5 text-xs" @click="discardChanges">
{{ t('grid.discard') }}
</Button>
<Button variant="ghost" size="sm" class="h-5 text-xs" @click="addRow">
<Plus class="w-3 h-3 mr-1" /> {{ t('grid.addRow') }}
</Button>
</template>
<span class="ml-auto flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="sm" class="h-5 text-xs px-1.5">
{{ pageSize }}{{ t('grid.rowsPerPageShort') }}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem v-for="s in [50, 100, 500, 1000]" :key="s" @click="changePageSize(s)">
{{ s }} {{ t('grid.rowsPerPageShort') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="currentPage <= 1" @click="prevPage">
<ChevronLeft class="h-3 w-3" />
</Button>
<span>{{ currentPage }}</span>
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="!isFullPage" @click="nextPage">
<ChevronRight class="h-3 w-3" />
</Button>
</span>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" class="h-5 w-5">
<Download class="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="exportCsv">{{ t('grid.exportCsv') }}</DropdownMenuItem>
<DropdownMenuItem @click="exportJson">{{ t('grid.exportJson') }}</DropdownMenuItem>
<DropdownMenuItem @click="exportMarkdown">{{ t('grid.exportMarkdown') }}</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<span v-if="sqlOneLiner" class="truncate max-w-[30%] opacity-60" :title="sqlOneLiner">
{{ sqlOneLiner }}
</span>
</div>
</div>
</template>
<style scoped>
.data-grid-scroller {
overflow-anchor: none;
}
.data-grid-scroller :deep(.vue-recycle-scroller__item-wrapper) {
min-width: var(--total-w);
overflow: visible;
}
</style>
+29
View File
@@ -0,0 +1,29 @@
<script setup lang="ts">
import { computed } from "vue";
import { siMysql, siPostgresql, siSqlite, siRedis, siMongodb, siClickhouse, siDuckdb } from "simple-icons";
import { Database } from "lucide-vue-next";
const props = defineProps<{
dbType: string;
}>();
const icons: Record<string, { path: string; color: string }> = {
mysql: { path: siMysql.path, color: `#${siMysql.hex}` },
postgres: { path: siPostgresql.path, color: `#${siPostgresql.hex}` },
sqlite: { path: siSqlite.path, color: `#${siSqlite.hex}` },
redis: { path: siRedis.path, color: `#${siRedis.hex}` },
mongodb: { path: siMongodb.path, color: `#${siMongodb.hex}` },
clickhouse: { path: siClickhouse.path, color: `#${siClickhouse.hex}` },
duckdb: { path: siDuckdb.path, color: `#${siDuckdb.hex}` },
};
const iconData = computed(() => icons[props.dbType]);
const hasBrandIcon = computed(() => !!iconData.value);
</script>
<template>
<svg v-if="hasBrandIcon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" :fill="iconData.color">
<path :d="iconData.path" />
</svg>
<Database v-else class="text-blue-400" />
</template>
+264
View File
@@ -0,0 +1,264 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import { RefreshCw, Trash2, Plus, Save, ChevronLeft, ChevronRight } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import * as api from "@/lib/tauri";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
const { t } = useI18n();
const props = defineProps<{
connectionId: string;
database: string;
collection: string;
}>();
const documents = ref<any[]>([]);
const total = ref(0);
const loading = ref(false);
const page = ref(0);
const pageSize = 50;
const selectedIdx = ref<number | null>(null);
const editJson = ref("");
const isEditing = ref(false);
const isNew = ref(false);
const error = ref("");
async function load() {
loading.value = true;
error.value = "";
try {
const result = await api.mongoFindDocuments(
props.connectionId, props.database, props.collection,
page.value * pageSize, pageSize
);
documents.value = result.documents;
total.value = result.total;
} catch (e: any) {
error.value = String(e);
} finally {
loading.value = false;
}
}
function selectDoc(idx: number) {
selectedIdx.value = idx;
editJson.value = JSON.stringify(documents.value[idx], null, 2);
isEditing.value = false;
isNew.value = false;
}
function startNew() {
selectedIdx.value = null;
editJson.value = '{\n \n}';
isEditing.value = true;
isNew.value = true;
}
async function saveDoc() {
error.value = "";
try {
if (isNew.value) {
await api.mongoInsertDocument(props.connectionId, props.database, props.collection, editJson.value);
} else if (selectedIdx.value !== null) {
const doc = documents.value[selectedIdx.value];
const id = doc._id;
if (!id) { error.value = "No _id field"; return; }
const parsed = JSON.parse(editJson.value);
delete parsed._id;
await api.mongoUpdateDocument(props.connectionId, props.database, props.collection, id, JSON.stringify(parsed));
}
isEditing.value = false;
isNew.value = false;
await load();
} catch (e: any) {
error.value = String(e);
}
}
async function deleteDoc(idx: number) {
const doc = documents.value[idx];
const id = doc._id;
if (!id) return;
error.value = "";
try {
await api.mongoDeleteDocument(props.connectionId, props.database, props.collection, id);
if (selectedIdx.value === idx) { selectedIdx.value = null; editJson.value = ""; }
await load();
} catch (e: any) {
error.value = String(e);
}
}
function prevPage() {
if (page.value <= 0) return;
page.value--;
load();
}
function nextPage() {
if ((page.value + 1) * pageSize >= total.value) return;
page.value++;
load();
}
function docPreview(doc: any): string {
const id = doc._id || "";
const keys = Object.keys(doc).filter(k => k !== "_id").slice(0, 3);
const preview = keys.map(k => `${k}: ${JSON.stringify(doc[k]).substring(0, 30)}`).join(", ");
return `${id} — ${preview}`;
}
function highlightedJson(json: string): string {
const escaped = json
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
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);
</script>
<template>
<Splitpanes class="h-full">
<!-- Document list (left) -->
<Pane :size="30" :min-size="15" :max-size="50">
<div class="h-full flex flex-col overflow-hidden">
<div class="flex items-center gap-1 px-3 py-1.5 border-b shrink-0 text-xs text-muted-foreground">
<span>{{ total }} documents</span>
<span class="flex-1" />
<Button variant="ghost" size="icon" class="h-5 w-5" @click="startNew"><Plus class="h-3 w-3" /></Button>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="load"><RefreshCw class="h-3 w-3" /></Button>
</div>
<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)"
>
<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="deleteDoc(idx)">
<Trash2 class="w-3 h-3" />
</Button>
</div>
<div v-if="documents.length === 0 && !loading" class="px-3 py-8 text-center text-muted-foreground text-xs">
Empty collection
</div>
</div>
<!-- Pagination -->
<div class="flex items-center justify-center gap-2 px-3 py-1 border-t text-xs text-muted-foreground shrink-0">
<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">
<ChevronRight class="h-3 w-3" />
</Button>
</div>
</div>
</Pane>
<!-- Document viewer/editor (right) -->
<Pane :size="70">
<div class="h-full flex flex-col min-w-0 overflow-hidden">
<template v-if="selectedIdx !== null || isNew">
<div class="flex items-center gap-2 px-4 py-2 border-b bg-muted/30 shrink-0">
<Badge variant="secondary" class="text-xs">{{ isNew ? 'New' : documents[selectedIdx!]?._id }}</Badge>
<span class="flex-1" />
<Button v-if="!isEditing" variant="ghost" size="sm" class="h-6 text-xs" @click="isEditing = true">Edit</Button>
<template v-if="isEditing">
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="isEditing = false; isNew = false">{{ 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>
<textarea
v-if="isEditing"
v-model="editJson"
class="flex-1 p-4 font-mono text-xs bg-background resize-none outline-none"
/>
<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)"
/>
</div>
</template>
<div v-else class="h-full flex items-center justify-center text-muted-foreground text-sm">
Select a document
</div>
<div v-if="error" class="px-3 py-1.5 border-t bg-destructive/10 text-destructive text-xs shrink-0">
{{ error }}
</div>
</div>
</Pane>
</Splitpanes>
</template>
<style scoped>
.json-viewer {
tab-size: 2;
white-space: pre;
}
:deep(.json-key) {
color: #7c3aed;
font-weight: 600;
}
:deep(.json-string) {
color: #15803d;
}
:deep(.json-number) {
color: #b45309;
}
:deep(.json-boolean) {
color: #2563eb;
font-weight: 600;
}
:deep(.json-null) {
color: #64748b;
font-style: italic;
}
:global(.dark) :deep(.json-key) {
color: #c4b5fd;
}
:global(.dark) :deep(.json-string) {
color: #86efac;
}
:global(.dark) :deep(.json-number) {
color: #fbbf24;
}
:global(.dark) :deep(.json-boolean) {
color: #93c5fd;
}
:global(.dark) :deep(.json-null) {
color: #94a3b8;
}
</style>
+120
View File
@@ -0,0 +1,120 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import { Search, RefreshCw, Key } from "lucide-vue-next";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import RedisValueViewer from "./RedisValueViewer.vue";
import * as api from "@/lib/tauri";
import type { RedisKeyInfo } from "@/lib/tauri";
const { t } = useI18n();
const props = defineProps<{
connectionId: string;
db: number;
}>();
const keys = ref<RedisKeyInfo[]>([]);
const loading = ref(false);
const searchPattern = ref("*");
const selectedKey = ref<string | null>(null);
async function loadKeys() {
loading.value = true;
try {
keys.value = await api.redisScanKeys(props.connectionId, props.db, searchPattern.value, 1000);
} finally {
loading.value = false;
}
}
function selectKey(key: string) {
selectedKey.value = key;
}
function onKeyDeleted() {
if (selectedKey.value) {
keys.value = keys.value.filter((k) => k.key !== selectedKey.value);
selectedKey.value = null;
}
}
function typeColor(type: string): string {
switch (type) {
case "string": return "text-green-500";
case "list": return "text-blue-500";
case "set": return "text-purple-500";
case "zset": return "text-amber-500";
case "hash": return "text-orange-500";
default: return "text-muted-foreground";
}
}
onMounted(loadKeys);
</script>
<template>
<Splitpanes class="h-full">
<!-- Key list (left) -->
<Pane :size="30" :min-size="15" :max-size="50">
<div class="h-full flex flex-col overflow-hidden">
<!-- Search bar -->
<div class="flex items-center gap-1 px-2 py-1.5 border-b shrink-0">
<Search class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<Input
v-model="searchPattern"
class="h-6 text-xs border-0 shadow-none focus-visible:ring-0"
:placeholder="t('redis.pattern')"
@keydown.enter="loadKeys"
/>
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" @click="loadKeys">
<RefreshCw class="h-3 w-3" />
</Button>
</div>
<!-- Key count -->
<div class="px-3 py-1 text-xs text-muted-foreground border-b shrink-0">
{{ t('redis.keys', { count: keys.length }) }}
</div>
<!-- Key list -->
<div class="flex-1 overflow-y-auto">
<div
v-for="k in keys"
:key="k.key"
class="flex items-center gap-2 px-3 py-1.5 text-xs cursor-pointer hover:bg-accent/50 border-b border-border/50"
:class="{ 'bg-accent': selectedKey === k.key }"
@click="selectKey(k.key)"
>
<Key class="w-3 h-3 shrink-0" :class="typeColor(k.key_type)" />
<span class="truncate flex-1 font-mono">{{ k.key }}</span>
<Badge variant="outline" class="text-[10px] px-1 py-0 shrink-0">{{ k.key_type }}</Badge>
</div>
<div v-if="keys.length === 0 && !loading" class="px-3 py-8 text-center text-muted-foreground text-xs">
{{ t('redis.noKeys') }}
</div>
</div>
</div>
</Pane>
<!-- Value viewer (right) -->
<Pane :size="70">
<div class="h-full min-w-0">
<RedisValueViewer
v-if="selectedKey"
:key="selectedKey"
:connection-id="connectionId"
:key-name="selectedKey"
@deleted="onKeyDeleted"
/>
<div v-else class="h-full flex items-center justify-center text-muted-foreground text-sm">
{{ t('redis.selectKey') }}
</div>
</div>
</Pane>
</Splitpanes>
</template>
+197
View File
@@ -0,0 +1,197 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import { Copy, Trash2, Save, RefreshCw, Plus } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import * as api from "@/lib/tauri";
import type { RedisValue } from "@/lib/tauri";
const { t } = useI18n();
const props = defineProps<{
connectionId: string;
keyName: string;
}>();
const emit = defineEmits<{ deleted: [] }>();
const data = ref<RedisValue | null>(null);
const loading = ref(false);
const editValue = ref("");
const isEditing = ref(false);
const newField = ref("");
const newValue = ref("");
async function load() {
loading.value = true;
try {
data.value = await api.redisGetValue(props.connectionId, props.keyName);
if (data.value.key_type === "string") {
editValue.value = String(data.value.value);
}
} finally {
loading.value = false;
}
}
async function saveString() {
await api.redisSetString(props.connectionId, props.keyName, editValue.value);
isEditing.value = false;
await load();
}
async function deleteKey() {
await api.redisDeleteKey(props.connectionId, props.keyName);
emit("deleted");
}
function copyValue() {
if (!data.value) return;
const text = typeof data.value.value === "string" ? data.value.value : JSON.stringify(data.value.value, null, 2);
navigator.clipboard.writeText(text);
}
// Hash
async function hashSet() {
if (!newField.value) return;
await api.redisHashSet(props.connectionId, props.keyName, newField.value, newValue.value);
newField.value = "";
newValue.value = "";
await load();
}
async function hashDel(field: string) {
await api.redisHashDel(props.connectionId, props.keyName, field);
await load();
}
// List
async function listPush() {
if (!newValue.value) return;
await api.redisListPush(props.connectionId, props.keyName, newValue.value);
newValue.value = "";
await load();
}
async function listRemove(index: number) {
await api.redisListRemove(props.connectionId, props.keyName, index);
await load();
}
// Set
async function setAdd() {
if (!newValue.value) return;
await api.redisSetAdd(props.connectionId, props.keyName, newValue.value);
newValue.value = "";
await load();
}
async function setRemove(member: string) {
await api.redisSetRemove(props.connectionId, props.keyName, member);
await load();
}
function formatValue(val: any): string {
if (typeof val === "string") return val;
return JSON.stringify(val, null, 2);
}
onMounted(load);
</script>
<template>
<div class="h-full flex flex-col overflow-hidden">
<div v-if="loading" class="flex-1 flex items-center justify-center text-muted-foreground">
{{ t('common.loading') }}
</div>
<template v-else-if="data">
<!-- Header -->
<div class="flex items-center gap-2 px-4 py-2 border-b bg-muted/30 shrink-0">
<span class="font-mono text-sm font-medium truncate">{{ data.key }}</span>
<Badge variant="secondary" class="text-xs">{{ data.key_type }}</Badge>
<Badge v-if="data.ttl > 0" variant="outline" class="text-xs">TTL: {{ data.ttl }}s</Badge>
<Badge v-else-if="data.ttl === -1" variant="outline" class="text-xs opacity-50">{{ t('redis.noExpiry') }}</Badge>
<span class="flex-1" />
<Button variant="ghost" size="icon" class="h-7 w-7" @click="load"><RefreshCw class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="copyValue"><Copy class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-7 w-7 text-destructive" @click="deleteKey"><Trash2 class="h-3.5 w-3.5" /></Button>
</div>
<!-- String -->
<div v-if="data.key_type === 'string'" class="flex-1 flex flex-col overflow-hidden">
<textarea v-model="editValue" class="flex-1 p-4 font-mono text-sm bg-background resize-none outline-none" @input="isEditing = true" />
<div v-if="isEditing" class="px-4 py-2 border-t flex justify-end gap-2 shrink-0">
<Button variant="ghost" size="sm" @click="isEditing = false; editValue = String(data.value)">{{ t('grid.discard') }}</Button>
<Button size="sm" @click="saveString"><Save class="w-3 h-3 mr-1" /> {{ t('grid.save') }}</Button>
</div>
</div>
<!-- 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">{{ t('redis.items', { count: Array.isArray(data.value) ? data.value.length : 0 }) }}</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>
</div>
<div class="flex-1 overflow-y-auto">
<div v-for="(item, idx) in data.value" :key="idx" class="px-4 py-1.5 border-b text-sm font-mono hover:bg-accent/50 flex items-center gap-2 group">
<span class="text-muted-foreground text-xs w-8 shrink-0">{{ idx }}</span>
<span class="truncate flex-1">{{ item }}</span>
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive" @click="listRemove(Number(idx))"><Trash2 class="w-3 h-3" /></Button>
</div>
</div>
</div>
<!-- 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">{{ t('redis.items', { count: Array.isArray(data.value) ? data.value.length : 0 }) }}</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>
</div>
<div class="flex-1 overflow-y-auto">
<div v-for="(item, idx) in data.value" :key="idx" class="px-4 py-1.5 border-b text-sm font-mono hover:bg-accent/50 flex items-center gap-2 group">
<span class="truncate flex-1">{{ item }}</span>
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive" @click="setRemove(String(item))"><Trash2 class="w-3 h-3" /></Button>
</div>
</div>
</div>
<!-- Hash -->
<div v-else-if="data.key_type === 'hash'" 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">{{ t('redis.fields', { count: Object.keys(data.value || {}).length }) }}</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>
</div>
<div class="flex-1 overflow-y-auto">
<div v-for="(val, field) in data.value" :key="String(field)" class="px-4 py-1.5 border-b text-sm font-mono hover:bg-accent/50 flex items-center gap-3 group">
<span class="text-blue-500 shrink-0 min-w-24">{{ field }}</span>
<span class="truncate text-muted-foreground flex-1">{{ val }}</span>
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive" @click="hashDel(String(field))"><Trash2 class="w-3 h-3" /></Button>
</div>
</div>
</div>
<!-- Sorted Set (readonly) -->
<div v-else-if="data.key_type === 'zset'" class="flex-1 overflow-auto">
<div class="px-4 py-1 text-xs text-muted-foreground border-b">
{{ t('redis.members', { count: Array.isArray(data.value) ? data.value.length : 0 }) }}
</div>
<div v-for="(item, idx) in data.value" :key="idx" class="px-4 py-1.5 border-b text-sm font-mono hover:bg-accent/50 flex items-center gap-3">
<span class="text-muted-foreground text-xs w-16 shrink-0">{{ item.score }}</span>
<span class="truncate">{{ item.member }}</span>
</div>
</div>
<!-- Unknown -->
<div v-else class="flex-1 overflow-auto p-4">
<pre class="font-mono text-sm whitespace-pre-wrap">{{ formatValue(data.value) }}</pre>
</div>
</template>
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { useConnectionStore } from "@/stores/connectionStore";
import TreeItem from "./TreeItem.vue";
const { t } = useI18n();
const store = useConnectionStore();
</script>
<template>
<div class="text-sm select-none">
<TreeItem v-for="node in store.treeNodes" :key="node.id" :node="node" :depth="0" />
<div v-if="store.treeNodes.length === 0" class="px-3 py-8 text-center text-muted-foreground text-xs">
{{ t('sidebar.noConnections') }}
</div>
</div>
</template>
+300
View File
@@ -0,0 +1,300 @@
<script setup lang="ts">
import { ref, computed } from "vue";
import { useI18n } from "vue-i18n";
import {
Database, Table, Columns3, Eye, ChevronRight, ChevronDown,
Loader2, FolderOpen, Trash2, TerminalSquare, RefreshCw,
Copy, TableProperties, Key, Link, Zap, ListTree, Pencil,
} from "lucide-vue-next";
import {
ContextMenu, ContextMenuContent, ContextMenuItem,
ContextMenuSeparator, ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import type { TreeNode, TreeNodeType } from "@/types/database";
import * as api from "@/lib/tauri";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
const { t } = useI18n();
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
const props = defineProps<{
node: TreeNode;
depth: number;
}>();
function quoteIdent(name: string): string {
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
return config?.db_type === "mysql"
? `\`${name.replace(/`/g, "``")}\``
: `"${name.replace(/"/g, '""')}"`;
}
function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
switch (node.type) {
case "connection":
return null;
case "database":
return { icon: Database, colorClass: "text-yellow-500" };
case "schema":
return { icon: FolderOpen, colorClass: "text-sky-400" };
case "table":
return { icon: Table, colorClass: "text-green-500" };
case "view":
return { icon: Eye, colorClass: "text-purple-500" };
case "column":
return { icon: Columns3, colorClass: "text-muted-foreground" };
case "group-columns":
return { icon: ListTree, colorClass: "text-green-400" };
case "group-indexes":
return { icon: Key, colorClass: "text-amber-500" };
case "group-fkeys":
return { icon: Link, colorClass: "text-blue-400" };
case "group-triggers":
return { icon: Zap, colorClass: "text-orange-400" };
case "index":
return { icon: Key, colorClass: "text-amber-400" };
case "fkey":
return { icon: Link, colorClass: "text-blue-300" };
case "trigger":
return { icon: Zap, colorClass: "text-orange-300" };
case "redis-db":
return { icon: Database, colorClass: "text-red-400" };
case "redis-key":
return { icon: Key, colorClass: "text-red-300" };
case "mongo-db":
return { icon: Database, colorClass: "text-green-500" };
case "mongo-collection":
return { icon: Table, colorClass: "text-green-400" };
default:
return { icon: Database, colorClass: "text-muted-foreground" };
}
}
const leafTypes: Set<TreeNodeType> = new Set(["column", "index", "fkey", "trigger", "redis-key"]);
const groupTypes: Set<TreeNodeType> = new Set(["group-columns", "group-indexes", "group-fkeys", "group-triggers"]);
function isGroupLabel(node: TreeNode): boolean {
return groupTypes.has(node.type);
}
async function toggle() {
const node = props.node;
if (node.isLoading) return;
if (node.isExpanded) { node.isExpanded = false; return; }
if (node.type === "connection" && node.connectionId) {
const config = connectionStore.getConfig(node.connectionId);
if (config?.db_type === "redis") {
await connectionStore.loadRedisDatabases(node.connectionId);
} else if (config?.db_type === "mongodb") {
await connectionStore.loadMongoDatabases(node.connectionId);
} else {
await connectionStore.loadDatabases(node.connectionId);
}
} else if (node.type === "redis-db" && node.connectionId && node.database) {
const tabTitle = `${connectionStore.getConfig(node.connectionId)?.name || "Redis"}:db${node.database}`;
queryStore.createTab(node.connectionId, node.database, tabTitle, "redis");
} else if (node.type === "mongo-db" && node.connectionId && node.database) {
await connectionStore.loadMongoCollections(node.connectionId, node.database);
} else if (node.type === "mongo-collection" && node.connectionId && node.database) {
const tabTitle = `${node.database}.${node.label}`;
const tab = queryStore.createTab(node.connectionId, node.database, tabTitle, "mongo");
queryStore.updateSql(tab, node.label);
} else if (node.type === "database" && node.connectionId && node.database) {
const config = connectionStore.getConfig(node.connectionId);
if (config?.db_type === "postgres") {
await connectionStore.loadSchemas(node.connectionId, node.database);
} else {
await connectionStore.loadTables(node.connectionId, node.database);
}
} else if (node.type === "schema" && node.connectionId && node.database && node.schema) {
await connectionStore.loadTables(node.connectionId, node.database, node.schema);
} else if ((node.type === "table" || node.type === "view") && node.connectionId && node.database) {
await connectionStore.loadTableGroups(node.connectionId, node.database, node.label, node.schema);
} else if (node.type === "group-columns" && node.connectionId && node.database && node.tableName) {
await connectionStore.loadColumns(node.connectionId, node.database, node.tableName, node.schema);
} else if (node.type === "group-indexes" && node.connectionId && node.database && node.tableName) {
await connectionStore.loadIndexes(node.connectionId, node.database, node.tableName, node.schema);
} else if (node.type === "group-fkeys" && node.connectionId && node.database && node.tableName) {
await connectionStore.loadForeignKeys(node.connectionId, node.database, node.tableName, node.schema);
} else if (node.type === "group-triggers" && node.connectionId && node.database && node.tableName) {
await connectionStore.loadTriggers(node.connectionId, node.database, node.tableName, node.schema);
}
}
async function openData() {
const node = props.node;
if (!(node.type === "table" || node.type === "view") || !node.connectionId || !node.database) return;
await connectionStore.ensureConnected(node.connectionId);
const config = connectionStore.getConfig(node.connectionId);
const qualifiedName = config?.db_type === "postgres" && node.schema
? `${quoteIdent(node.schema)}.${quoteIdent(node.label)}`
: quoteIdent(node.label);
const tabId = queryStore.createTab(node.connectionId, node.database, node.label, "data");
const querySchema = node.schema || node.database;
const columns = await api.getColumns(node.connectionId, node.database, querySchema, node.label);
const pks = columns.filter((c) => c.is_primary_key).map((c) => c.name);
const order = pks.length ? ` ORDER BY ${pks.map((pk) => `${quoteIdent(pk)} ASC`).join(", ")}` : "";
const sql = `SELECT * FROM ${qualifiedName}${order} LIMIT 100;`;
queryStore.updateSql(tabId, sql);
queryStore.setTableMeta(tabId, {
schema: node.schema,
tableName: node.label,
columns,
primaryKeys: pks,
});
queryStore.executeCurrentTab();
}
async function newQuery() {
const node = props.node;
if (!node.connectionId) return;
await connectionStore.ensureConnected(node.connectionId);
connectionStore.activeConnectionId = node.connectionId;
queryStore.createTab(node.connectionId, node.database || "", undefined, "query");
}
async function refresh() {
const node = props.node;
node.isExpanded = false;
node.children = [];
await toggle();
}
function deleteConnection() {
const node = props.node;
if (node.connectionId) {
connectionStore.disconnect(node.connectionId);
connectionStore.removeConnection(node.connectionId);
}
}
function copyName() {
navigator.clipboard.writeText(props.node.label);
}
function editConnection() {
if (props.node.connectionId) {
connectionStore.startEditing(props.node.connectionId);
}
}
const canExpand = !leafTypes.has(props.node.type);
const paddingLeft = `${props.depth * 16 + 8}px`;
const CHILDREN_PAGE_SIZE = 100;
const displayLimit = ref(CHILDREN_PAGE_SIZE);
const visibleChildren = computed(() => {
if (!props.node.children) return [];
return props.node.children.slice(0, displayLimit.value);
});
const hasMoreChildren = computed(() =>
(props.node.children?.length ?? 0) > displayLimit.value
);
const remainingCount = computed(() =>
(props.node.children?.length ?? 0) - displayLimit.value
);
function showMore() {
displayLimit.value += CHILDREN_PAGE_SIZE;
}
</script>
<template>
<ContextMenu>
<ContextMenuTrigger as-child>
<div>
<div
class="flex items-center gap-1.5 py-1 px-2 rounded-sm cursor-pointer hover:bg-accent transition-colors"
:style="{ paddingLeft }"
@click="canExpand && toggle()"
@dblclick="openData"
>
<template v-if="canExpand">
<Loader2 v-if="node.isLoading" class="w-3.5 h-3.5 shrink-0 animate-spin text-muted-foreground" />
<ChevronDown v-else-if="node.isExpanded" class="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
<ChevronRight v-else class="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
</template>
<span v-else class="w-3.5 h-3.5 shrink-0" />
<DatabaseIcon v-if="node.type === 'connection'" :db-type="connectionStore.getConfig(node.connectionId || '')?.db_type || 'postgres'" class="w-3.5 h-3.5 shrink-0" />
<component v-else :is="getIconInfo(node)?.icon || Database" class="w-3.5 h-3.5 shrink-0" :class="getIconInfo(node)?.colorClass" />
<span class="truncate">{{ isGroupLabel(node) ? t(node.label) : node.label }}</span>
</div>
<template v-if="node.isExpanded && node.children">
<TreeItem v-for="child in visibleChildren" :key="child.id" :node="child" :depth="depth + 1" />
<div
v-if="hasMoreChildren"
class="flex items-center gap-1.5 py-1 px-2 cursor-pointer hover:bg-accent text-xs text-muted-foreground"
:style="{ paddingLeft: `${(depth + 1) * 16 + 8}px` }"
@click="showMore"
>
<span>{{ t('sidebar.showMore', { count: Math.min(CHILDREN_PAGE_SIZE, remainingCount) }) }}</span>
</div>
</template>
</div>
</ContextMenuTrigger>
<ContextMenuContent class="w-48">
<template v-if="node.type === 'connection'">
<ContextMenuItem @click="toggle">
<Plug class="w-3.5 h-3.5 mr-2" /> {{ t('contextMenu.openConnection') }}
</ContextMenuItem>
<ContextMenuItem @click="newQuery">
<TerminalSquare class="w-3.5 h-3.5 mr-2" /> {{ t('contextMenu.newQuery') }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem @click="refresh">
<RefreshCw class="w-3.5 h-3.5 mr-2" /> {{ t('contextMenu.refreshChildren') }}
</ContextMenuItem>
<ContextMenuItem @click="editConnection">
<Pencil class="w-3.5 h-3.5 mr-2" /> {{ t('contextMenu.editConnection') }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem class="text-destructive" @click="deleteConnection">
<Trash2 class="w-3.5 h-3.5 mr-2" /> {{ t('contextMenu.deleteConnection') }}
</ContextMenuItem>
</template>
<template v-if="node.type === 'database' || node.type === 'schema'">
<ContextMenuItem @click="newQuery">
<TerminalSquare class="w-3.5 h-3.5 mr-2" /> {{ t('contextMenu.newQuery') }}
</ContextMenuItem>
<ContextMenuItem @click="refresh">
<RefreshCw class="w-3.5 h-3.5 mr-2" /> {{ t('contextMenu.refreshChildren') }}
</ContextMenuItem>
</template>
<template v-if="node.type === 'table' || node.type === 'view'">
<ContextMenuItem @click="openData">
<TableProperties class="w-3.5 h-3.5 mr-2" /> {{ t('contextMenu.viewData') }}
</ContextMenuItem>
<ContextMenuItem @click="newQuery">
<TerminalSquare class="w-3.5 h-3.5 mr-2" /> {{ t('contextMenu.newQuery') }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem @click="refresh">
<RefreshCw class="w-3.5 h-3.5 mr-2" /> {{ t('contextMenu.refreshChildren') }}
</ContextMenuItem>
</template>
<template v-if="isGroupLabel(node)">
<ContextMenuItem @click="refresh">
<RefreshCw class="w-3.5 h-3.5 mr-2" /> {{ t('contextMenu.refreshChildren') }}
</ContextMenuItem>
</template>
<ContextMenuSeparator />
<ContextMenuItem @click="copyName">
<Copy class="w-3.5 h-3.5 mr-2" /> {{ t('contextMenu.copyName') }}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</template>
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { BadgeVariants } from '.'
import { reactiveOmit } from '@vueuse/core'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
import { badgeVariants } from '.'
const props = defineProps<PrimitiveProps & {
variant?: BadgeVariants['variant']
class?: HTMLAttributes['class']
}>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<Primitive
data-slot="badge"
:data-variant="variant"
:class="cn(badgeVariants({ variant }), props.class)"
v-bind="delegatedProps"
>
<slot />
</Primitive>
</template>
+24
View File
@@ -0,0 +1,24 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Badge } from './Badge.vue'
export const badgeVariants = cva(
'h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none',
{
variants: {
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',
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',
},
},
defaultVariants: {
variant: 'default',
},
},
)
export type BadgeVariants = VariantProps<typeof badgeVariants>
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '.'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '.'
interface Props extends PrimitiveProps {
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
}
const props = withDefaults(defineProps<Props>(), {
as: 'button',
})
</script>
<template>
<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>
+35
View File
@@ -0,0 +1,35 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Button } from './Button.vue'
export const buttonVariants = cva(
'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 active:not-aria-[haspopup]:translate-y-px [&_svg:not([class*=size-])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
{
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',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
'default': 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
'xs': 'h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs 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',
'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-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
'icon-lg': 'size-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>
@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { ContextMenuRootEmits, ContextMenuRootProps } from 'reka-ui'
import { ContextMenuRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<ContextMenuRootProps>()
const emits = defineEmits<ContextMenuRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<ContextMenuRoot
data-slot="context-menu"
v-bind="forwarded"
>
<slot />
</ContextMenuRoot>
</template>
@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { ContextMenuCheckboxItemEmits, ContextMenuCheckboxItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { CheckIcon } from 'lucide-vue-next'
import {
ContextMenuCheckboxItem,
ContextMenuItemIndicator,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<ContextMenuCheckboxItemProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<ContextMenuCheckboxItemEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ContextMenuCheckboxItem
data-slot="context-menu-checkbox-item"
v-bind="forwarded"
:class="cn(
'focus:bg-accent focus:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)"
>
<span class="absolute right-2 pointer-events-none">
<ContextMenuItemIndicator>
<slot name="indicator-icon">
<CheckIcon />
</slot>
</ContextMenuItemIndicator>
</span>
<slot />
</ContextMenuCheckboxItem>
</template>
@@ -0,0 +1,37 @@
<script setup lang="ts">
import type { ContextMenuContentEmits, ContextMenuContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
ContextMenuContent,
ContextMenuPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
defineOptions({
inheritAttrs: false,
})
const props = defineProps<ContextMenuContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<ContextMenuContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ContextMenuPortal>
<ContextMenuContent
data-slot="context-menu-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="cn(
'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 bg-popover text-popover-foreground min-w-36 rounded-lg p-1 shadow-md ring-1 duration-100 cn-menu-translucent z-50 max-h-(--reka-context-menu-content-available-height) origin-(--reka-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto',
props.class,
)"
>
<slot />
</ContextMenuContent>
</ContextMenuPortal>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { ContextMenuGroupProps } from 'reka-ui'
import { ContextMenuGroup } from 'reka-ui'
const props = defineProps<ContextMenuGroupProps>()
</script>
<template>
<ContextMenuGroup
data-slot="context-menu-group"
v-bind="props"
>
<slot />
</ContextMenuGroup>
</template>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { ContextMenuItemEmits, ContextMenuItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
ContextMenuItem,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<ContextMenuItemProps & {
class?: HTMLAttributes['class']
inset?: boolean
variant?: 'default' | 'destructive'
}>(), {
variant: 'default',
})
const emits = defineEmits<ContextMenuItemEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ContextMenuItem
data-slot="context-menu-item"
:data-inset="inset ? '' : undefined"
:data-variant="variant"
v-bind="forwarded"
:class="cn(
'focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive focus:*:[svg]:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 group/context-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)"
>
<slot />
</ContextMenuItem>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { ContextMenuLabelProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ContextMenuLabel } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<ContextMenuLabelProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
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)"
>
<slot />
</ContextMenuLabel>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { ContextMenuPortalProps } from 'reka-ui'
import { ContextMenuPortal } from 'reka-ui'
const props = defineProps<ContextMenuPortalProps>()
</script>
<template>
<ContextMenuPortal
data-slot="context-menu-portal"
v-bind="props"
>
<slot />
</ContextMenuPortal>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { ContextMenuRadioGroupEmits, ContextMenuRadioGroupProps } from 'reka-ui'
import {
ContextMenuRadioGroup,
useForwardPropsEmits,
} from 'reka-ui'
const props = defineProps<ContextMenuRadioGroupProps>()
const emits = defineEmits<ContextMenuRadioGroupEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<ContextMenuRadioGroup
data-slot="context-menu-radio-group"
v-bind="forwarded"
>
<slot />
</ContextMenuRadioGroup>
</template>
@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { ContextMenuRadioItemEmits, ContextMenuRadioItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { CheckIcon } from 'lucide-vue-next'
import {
ContextMenuItemIndicator,
ContextMenuRadioItem,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<ContextMenuRadioItemProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<ContextMenuRadioItemEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ContextMenuRadioItem
data-slot="context-menu-radio-item"
v-bind="forwarded"
:class="cn(
'focus:bg-accent focus:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)"
>
<span class="absolute right-2 pointer-events-none">
<ContextMenuItemIndicator>
<slot name="indicator-icon">
<CheckIcon />
</slot>
</ContextMenuItemIndicator>
</span>
<slot />
</ContextMenuRadioItem>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { ContextMenuSeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
ContextMenuSeparator,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<ContextMenuSeparatorProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<ContextMenuSeparator
data-slot="context-menu-separator"
v-bind="delegatedProps"
:class="cn('bg-border -mx-1 my-1 h-px', props.class)"
/>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</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)"
>
<slot />
</span>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { ContextMenuSubEmits, ContextMenuSubProps } from 'reka-ui'
import {
ContextMenuSub,
useForwardPropsEmits,
} from 'reka-ui'
const props = defineProps<ContextMenuSubProps>()
const emits = defineEmits<ContextMenuSubEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<ContextMenuSub
data-slot="context-menu-sub"
v-bind="forwarded"
>
<slot />
</ContextMenuSub>
</template>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
ContextMenuSubContent,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DropdownMenuSubContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ContextMenuSubContent
data-slot="context-menu-sub-content"
v-bind="forwarded"
:class="
cn(
'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 bg-popover text-popover-foreground min-w-32 rounded-lg border p-1 shadow-lg duration-100 cn-menu-translucent z-50 origin-(--reka-context-menu-content-transform-origin) overflow-hidden',
props.class,
)
"
>
<slot />
</ContextMenuSubContent>
</template>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { ContextMenuSubTriggerProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ChevronRightIcon } from 'lucide-vue-next'
import {
ContextMenuSubTrigger,
useForwardProps,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<ContextMenuSubTriggerProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<ContextMenuSubTrigger
data-slot="context-menu-sub-trigger"
:data-inset="inset ? '' : undefined"
v-bind="forwardedProps"
:class="cn(
'focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)"
>
<slot />
<ChevronRightIcon class="cn-rtl-flip ml-auto" />
</ContextMenuSubTrigger>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { ContextMenuTriggerProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ContextMenuTrigger, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<ContextMenuTriggerProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<ContextMenuTrigger
data-slot="context-menu-trigger"
v-bind="forwardedProps"
:class="cn('select-none', props.class)"
>
<slot />
</ContextMenuTrigger>
</template>
+14
View File
@@ -0,0 +1,14 @@
export { default as ContextMenu } from './ContextMenu.vue'
export { default as ContextMenuCheckboxItem } from './ContextMenuCheckboxItem.vue'
export { default as ContextMenuContent } from './ContextMenuContent.vue'
export { default as ContextMenuGroup } from './ContextMenuGroup.vue'
export { default as ContextMenuItem } from './ContextMenuItem.vue'
export { default as ContextMenuLabel } from './ContextMenuLabel.vue'
export { default as ContextMenuRadioGroup } from './ContextMenuRadioGroup.vue'
export { default as ContextMenuRadioItem } from './ContextMenuRadioItem.vue'
export { default as ContextMenuSeparator } from './ContextMenuSeparator.vue'
export { default as ContextMenuShortcut } from './ContextMenuShortcut.vue'
export { default as ContextMenuSub } from './ContextMenuSub.vue'
export { default as ContextMenuSubContent } from './ContextMenuSubContent.vue'
export { default as ContextMenuSubTrigger } from './ContextMenuSubTrigger.vue'
export { default as ContextMenuTrigger } from './ContextMenuTrigger.vue'
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
import { DialogRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DialogRoot
v-slot="slotProps"
data-slot="dialog"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</DialogRoot>
</template>
+15
View File
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogCloseProps } from 'reka-ui'
import { DialogClose } from 'reka-ui'
const props = defineProps<DialogCloseProps>()
</script>
<template>
<DialogClose
data-slot="dialog-close"
v-bind="props"
>
<slot />
</DialogClose>
</template>
@@ -0,0 +1,53 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { XIcon } from 'lucide-vue-next'
import {
DialogClose,
DialogContent,
DialogPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import DialogOverlay from './DialogOverlay.vue'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes['class'], showCloseButton?: boolean }>(), {
showCloseButton: true,
})
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay />
<DialogContent
data-slot="dialog-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="cn('bg-popover 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 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-sm ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none', props.class)"
>
<slot />
<DialogClose
v-if="showCloseButton"
data-slot="dialog-close"
as-child
>
<Button variant="ghost" class="absolute top-2 right-2" size="icon-sm">
<XIcon />
<span class="sr-only">Close</span>
</Button>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DialogDescriptionProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogDescription, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
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)"
>
<slot />
</DialogDescription>
</template>
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { DialogClose } from 'reka-ui'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
const props = withDefaults(defineProps<{
class?: HTMLAttributes['class']
showCloseButton?: boolean
}>(), {
showCloseButton: false,
})
</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)"
>
<slot />
<DialogClose v-if="showCloseButton" as-child>
<Button variant="outline">
Close
</Button>
</DialogClose>
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="dialog-header"
:class="cn('gap-2 flex flex-col', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DialogOverlayProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogOverlay } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes['class'] }>()
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 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50', props.class)"
>
<slot />
</DialogOverlay>
</template>
@@ -0,0 +1,60 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { XIcon } from 'lucide-vue-next'
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
defineOptions({
inheritAttrs: false,
})
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<DialogContent
:class="
cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
props.class,
)
"
v-bind="{ ...$attrs, ...forwarded }"
@pointer-down-outside="(event) => {
const originalEvent = event.detail.originalEvent;
const target = originalEvent.target as HTMLElement;
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
event.preventDefault();
}
}"
>
<slot />
<DialogClose
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
>
<XIcon class="w-4 h-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</template>
+23
View File
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DialogTitleProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogTitle, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
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)"
>
<slot />
</DialogTitle>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogTriggerProps } from 'reka-ui'
import { DialogTrigger } from 'reka-ui'
const props = defineProps<DialogTriggerProps>()
</script>
<template>
<DialogTrigger
data-slot="dialog-trigger"
v-bind="props"
>
<slot />
</DialogTrigger>
</template>
+10
View File
@@ -0,0 +1,10 @@
export { default as Dialog } from './Dialog.vue'
export { default as DialogClose } from './DialogClose.vue'
export { default as DialogContent } from './DialogContent.vue'
export { default as DialogDescription } from './DialogDescription.vue'
export { default as DialogFooter } from './DialogFooter.vue'
export { default as DialogHeader } from './DialogHeader.vue'
export { default as DialogOverlay } from './DialogOverlay.vue'
export { default as DialogScrollContent } from './DialogScrollContent.vue'
export { default as DialogTitle } from './DialogTitle.vue'
export { default as DialogTrigger } from './DialogTrigger.vue'
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { DropdownMenuRootEmits, DropdownMenuRootProps } from 'reka-ui'
import { DropdownMenuRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<DropdownMenuRootProps>()
const emits = defineEmits<DropdownMenuRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DropdownMenuRoot
v-slot="slotProps"
data-slot="dropdown-menu"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</DropdownMenuRoot>
</template>
@@ -0,0 +1,43 @@
<script setup lang="ts">
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { CheckIcon } from 'lucide-vue-next'
import {
DropdownMenuCheckboxItem,
DropdownMenuItemIndicator,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DropdownMenuCheckboxItemEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuCheckboxItem
data-slot="dropdown-menu-checkbox-item"
v-bind="forwarded"
:class="cn(
'focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)"
>
<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 />
</slot>
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuCheckboxItem>
</template>
@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
DropdownMenuContent,
DropdownMenuPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes['class'] }>(),
{
align: 'start',
sideOffset: 4,
},
)
const emits = defineEmits<DropdownMenuContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuPortal>
<DropdownMenuContent
data-slot="dropdown-menu-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="cn('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 bg-popover text-popover-foreground min-w-32 rounded-lg p-1 shadow-md ring-1 duration-100 cn-menu-translucent z-50 max-h-(--reka-dropdown-menu-content-available-height) w-(--reka-dropdown-menu-trigger-width) origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto data-[state=closed]:overflow-hidden', props.class)"
>
<slot />
</DropdownMenuContent>
</DropdownMenuPortal>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DropdownMenuGroupProps } from 'reka-ui'
import { DropdownMenuGroup } from 'reka-ui'
const props = defineProps<DropdownMenuGroupProps>()
</script>
<template>
<DropdownMenuGroup
data-slot="dropdown-menu-group"
v-bind="props"
>
<slot />
</DropdownMenuGroup>
</template>
@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { DropdownMenuItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DropdownMenuItem, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<DropdownMenuItemProps & {
class?: HTMLAttributes['class']
inset?: boolean
variant?: 'default' | 'destructive'
}>(), {
variant: 'default',
})
const delegatedProps = reactiveOmit(props, 'inset', 'variant', 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DropdownMenuItem
data-slot="dropdown-menu-item"
:data-inset="inset ? '' : undefined"
:data-variant="variant"
v-bind="forwardedProps"
:class="cn('focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0', props.class)"
>
<slot />
</DropdownMenuItem>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DropdownMenuLabelProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DropdownMenuLabel, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DropdownMenuLabelProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
const delegatedProps = reactiveOmit(props, 'class', 'inset')
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)"
>
<slot />
</DropdownMenuLabel>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DropdownMenuRadioGroupEmits, DropdownMenuRadioGroupProps } from 'reka-ui'
import {
DropdownMenuRadioGroup,
useForwardPropsEmits,
} from 'reka-ui'
const props = defineProps<DropdownMenuRadioGroupProps>()
const emits = defineEmits<DropdownMenuRadioGroupEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DropdownMenuRadioGroup
data-slot="dropdown-menu-radio-group"
v-bind="forwarded"
>
<slot />
</DropdownMenuRadioGroup>
</template>
@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { CheckIcon } from 'lucide-vue-next'
import {
DropdownMenuItemIndicator,
DropdownMenuRadioItem,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DropdownMenuRadioItemEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuRadioItem
data-slot="dropdown-menu-radio-item"
v-bind="forwarded"
:class="cn(
'focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)"
>
<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 />
</slot>
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuRadioItem>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DropdownMenuSeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
DropdownMenuSeparator,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DropdownMenuSeparatorProps & {
class?: HTMLAttributes['class']
}>()
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)"
/>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</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)"
>
<slot />
</span>
</template>
@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { DropdownMenuSubEmits, DropdownMenuSubProps } from 'reka-ui'
import {
DropdownMenuSub,
useForwardPropsEmits,
} from 'reka-ui'
const props = defineProps<DropdownMenuSubProps>()
const emits = defineEmits<DropdownMenuSubEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DropdownMenuSub v-slot="slotProps" data-slot="dropdown-menu-sub" v-bind="forwarded">
<slot v-bind="slotProps" />
</DropdownMenuSub>
</template>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
DropdownMenuSubContent,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DropdownMenuSubContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuSubContent
data-slot="dropdown-menu-sub-content"
v-bind="forwarded"
:class="cn('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 bg-popover text-popover-foreground min-w-[96px] rounded-lg p-1 shadow-lg ring-1 duration-100 cn-menu-translucent z-50 origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden', props.class)"
>
<slot />
</DropdownMenuSubContent>
</template>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { DropdownMenuSubTriggerProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ChevronRightIcon } from 'lucide-vue-next'
import {
DropdownMenuSubTrigger,
useForwardProps,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
const delegatedProps = reactiveOmit(props, 'class', 'inset')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DropdownMenuSubTrigger
data-slot="dropdown-menu-sub-trigger"
:data-inset="inset ? '' : undefined"
v-bind="forwardedProps"
:class="cn(
'focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)"
>
<slot />
<ChevronRightIcon class="cn-rtl-flip ml-auto" />
</DropdownMenuSubTrigger>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { DropdownMenuTriggerProps } from 'reka-ui'
import { DropdownMenuTrigger, useForwardProps } from 'reka-ui'
const props = defineProps<DropdownMenuTriggerProps>()
const forwardedProps = useForwardProps(props)
</script>
<template>
<DropdownMenuTrigger
data-slot="dropdown-menu-trigger"
v-bind="forwardedProps"
>
<slot />
</DropdownMenuTrigger>
</template>
+16
View File
@@ -0,0 +1,16 @@
export { default as DropdownMenu } from './DropdownMenu.vue'
export { default as DropdownMenuCheckboxItem } from './DropdownMenuCheckboxItem.vue'
export { default as DropdownMenuContent } from './DropdownMenuContent.vue'
export { default as DropdownMenuGroup } from './DropdownMenuGroup.vue'
export { default as DropdownMenuItem } from './DropdownMenuItem.vue'
export { default as DropdownMenuLabel } from './DropdownMenuLabel.vue'
export { default as DropdownMenuRadioGroup } from './DropdownMenuRadioGroup.vue'
export { default as DropdownMenuRadioItem } from './DropdownMenuRadioItem.vue'
export { default as DropdownMenuSeparator } from './DropdownMenuSeparator.vue'
export { default as DropdownMenuShortcut } from './DropdownMenuShortcut.vue'
export { default as DropdownMenuSub } from './DropdownMenuSub.vue'
export { default as DropdownMenuSubContent } from './DropdownMenuSubContent.vue'
export { default as DropdownMenuSubTrigger } from './DropdownMenuSubTrigger.vue'
export { default as DropdownMenuTrigger } from './DropdownMenuTrigger.vue'
export { DropdownMenuPortal } from 'reka-ui'
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { useVModel } from '@vueuse/core'
import { cn } from '@/lib/utils'
const props = defineProps<{
defaultValue?: string | number
modelValue?: string | number
class?: HTMLAttributes['class']
}>()
const emits = defineEmits<{
(e: 'update:modelValue', payload: string | number): void
}>()
const modelValue = useVModel(props, 'modelValue', emits, {
passive: true,
defaultValue: props.defaultValue,
})
</script>
<template>
<input
v-model="modelValue"
data-slot="input"
:class="cn(
'dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)"
>
</template>
+1
View File
@@ -0,0 +1 @@
export { default as Input } from './Input.vue'
+26
View File
@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { LabelProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { Label } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<LabelProps & { class?: HTMLAttributes['class'] }>()
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,
)
"
>
<slot />
</Label>
</template>
+1
View File
@@ -0,0 +1 @@
export { default as Label } from './Label.vue'
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { ScrollAreaRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
ScrollAreaCorner,
ScrollAreaRoot,
ScrollAreaViewport,
} from 'reka-ui'
import { cn } from '@/lib/utils'
import ScrollBar from './ScrollBar.vue'
const props = defineProps<ScrollAreaRootProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<ScrollAreaRoot
data-slot="scroll-area"
v-bind="delegatedProps"
:class="cn('relative', props.class)"
>
<ScrollAreaViewport
data-slot="scroll-area-viewport"
class="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
<slot />
</ScrollAreaViewport>
<ScrollBar />
<ScrollAreaCorner />
</ScrollAreaRoot>
</template>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { ScrollAreaScrollbarProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ScrollAreaScrollbar, ScrollAreaThumb } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<ScrollAreaScrollbarProps & { class?: HTMLAttributes['class'] }>(), {
orientation: 'vertical',
})
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<ScrollAreaScrollbar
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)"
>
<ScrollAreaThumb
data-slot="scroll-area-thumb"
class="rounded-full relative flex-1 bg-border"
/>
</ScrollAreaScrollbar>
</template>
+2
View File
@@ -0,0 +1,2 @@
export { default as ScrollArea } from './ScrollArea.vue'
export { default as ScrollBar } from './ScrollBar.vue'
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { SelectRootEmits, SelectRootProps } from 'reka-ui'
import { SelectRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<SelectRootProps>()
const emits = defineEmits<SelectRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<SelectRoot
v-slot="slotProps"
data-slot="select"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</SelectRoot>
</template>
@@ -0,0 +1,58 @@
<script setup lang="ts">
import type { SelectContentEmits, SelectContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
SelectContent,
SelectPortal,
SelectViewport,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
import { SelectScrollDownButton, SelectScrollUpButton } from '.'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<SelectContentProps & { class?: HTMLAttributes['class'] }>(),
{
position: 'item-aligned',
align: 'center',
},
)
const emits = defineEmits<SelectContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SelectPortal>
<SelectContent
data-slot="select-content"
:data-align-trigger="position === 'item-aligned'"
v-bind="{ ...$attrs, ...forwarded }"
:class="cn(
'bg-popover 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 shadow-md 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'
&& '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 data-[position=popper]:min-w-[var(--reka-select-trigger-width)]',
)"
>
<slot />
</SelectViewport>
<SelectScrollDownButton />
</SelectContent>
</SelectPortal>
</template>
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { SelectGroupProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { SelectGroup } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SelectGroupProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<SelectGroup
data-slot="select-group"
v-bind="delegatedProps"
:class="cn('scroll-my-1 p-1', props.class)"
>
<slot />
</SelectGroup>
</template>
+45
View File
@@ -0,0 +1,45 @@
<script setup lang="ts">
import type { SelectItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { CheckIcon } from 'lucide-vue-next'
import {
SelectItem,
SelectItemIndicator,
SelectItemText,
useForwardProps,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SelectItemProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectItem
data-slot="select-item"
v-bind="forwardedProps"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*=size-])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)
"
>
<span class="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectItemIndicator>
<slot name="indicator-icon">
<CheckIcon class="pointer-events-none" />
</slot>
</SelectItemIndicator>
</span>
<SelectItemText>
<slot />
</SelectItemText>
</SelectItem>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { SelectItemTextProps } from 'reka-ui'
import { SelectItemText } from 'reka-ui'
const props = defineProps<SelectItemTextProps>()
</script>
<template>
<SelectItemText
data-slot="select-item-text"
v-bind="props"
>
<slot />
</SelectItemText>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { SelectLabelProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { SelectLabel } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes['class'] }>()
</script>
<template>
<SelectLabel
data-slot="select-label"
:class="cn('text-muted-foreground px-1.5 py-1 text-xs', props.class)"
>
<slot />
</SelectLabel>
</template>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { SelectScrollDownButtonProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ChevronDownIcon } from 'lucide-vue-next'
import { SelectScrollDownButton, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
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)"
>
<slot>
<ChevronDownIcon />
</slot>
</SelectScrollDownButton>
</template>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { SelectScrollUpButtonProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ChevronUpIcon } from 'lucide-vue-next'
import { SelectScrollUpButton, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
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)"
>
<slot>
<ChevronUpIcon />
</slot>
</SelectScrollUpButton>
</template>
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { SelectSeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { SelectSeparator } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes['class'] }>()
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)"
/>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { SelectTriggerProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ChevronDownIcon } from 'lucide-vue-next'
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 delegatedProps = reactiveOmit(props, 'class', 'size')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectTrigger
data-slot="select-trigger"
:data-size="size"
v-bind="forwardedProps"
:class="cn(
'border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm transition-colors select-none focus-visible:ring-3 aria-invalid:ring-3 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*=size-])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)"
>
<slot />
<SelectIcon as-child>
<ChevronDownIcon class="text-muted-foreground size-4 pointer-events-none" />
</SelectIcon>
</SelectTrigger>
</template>
+15
View File
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { SelectValueProps } from 'reka-ui'
import { SelectValue } from 'reka-ui'
const props = defineProps<SelectValueProps>()
</script>
<template>
<SelectValue
data-slot="select-value"
v-bind="props"
>
<slot />
</SelectValue>
</template>
+11
View File
@@ -0,0 +1,11 @@
export { default as Select } from './Select.vue'
export { default as SelectContent } from './SelectContent.vue'
export { default as SelectGroup } from './SelectGroup.vue'
export { default as SelectItem } from './SelectItem.vue'
export { default as SelectItemText } from './SelectItemText.vue'
export { default as SelectLabel } from './SelectLabel.vue'
export { default as SelectScrollDownButton } from './SelectScrollDownButton.vue'
export { default as SelectScrollUpButton } from './SelectScrollUpButton.vue'
export { default as SelectSeparator } from './SelectSeparator.vue'
export { default as SelectTrigger } from './SelectTrigger.vue'
export { default as SelectValue } from './SelectValue.vue'
+29
View File
@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { SeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { Separator } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<
SeparatorProps & { class?: HTMLAttributes['class'] }
>(), {
orientation: 'horizontal',
decorative: true,
})
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,
)
"
/>
</template>
+1
View File
@@ -0,0 +1 @@
export { default as Separator } from './Separator.vue'
+25
View File
@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { TabsRootEmits, TabsRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { TabsRoot, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<TabsRootProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<TabsRootEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<TabsRoot
v-slot="slotProps"
data-slot="tabs"
:data-orientation="forwarded.orientation || 'horizontal'"
v-bind="forwarded"
:class="cn('gap-2 group/tabs flex data-horizontal:flex-col', props.class)"
>
<slot v-bind="slotProps" />
</TabsRoot>
</template>
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { TabsContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { TabsContent } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<TabsContentProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<TabsContent
data-slot="tabs-content"
:class="cn('text-sm flex-1 outline-none', props.class)"
v-bind="delegatedProps"
>
<slot />
</TabsContent>
</template>
+29
View File
@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { TabsListProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { TabsListVariants } from '.'
import { reactiveOmit } from '@vueuse/core'
import { TabsList } from 'reka-ui'
import { cn } from '@/lib/utils'
import { tabsListVariants } from '.'
const props = withDefaults(defineProps<TabsListProps & {
class?: HTMLAttributes['class']
variant?: TabsListVariants['variant']
}>(), {
variant: 'default',
})
const delegatedProps = reactiveOmit(props, 'class', 'variant')
</script>
<template>
<TabsList
data-slot="tabs-list"
:data-variant="variant"
v-bind="delegatedProps"
:class="cn(tabsListVariants({ variant }), props.class)"
>
<slot />
</TabsList>
</template>
+29
View File
@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { TabsTriggerProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { TabsTrigger, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<TabsTriggerProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<TabsTrigger
data-slot="tabs-trigger"
:class="cn(
'gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg:not([class*=size-])]:size-4 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0',
'group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent',
'data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground',
'after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100',
props.class,
)"
v-bind="forwardedProps"
>
<slot />
</TabsTrigger>
</template>
+24
View File
@@ -0,0 +1,24 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Tabs } from './Tabs.vue'
export { default as TabsContent } from './TabsContent.vue'
export { default as TabsList } from './TabsList.vue'
export { default as TabsTrigger } from './TabsTrigger.vue'
export const tabsListVariants = cva(
'rounded-lg p-[3px] group-data-horizontal/tabs:h-8 data-[variant=line]:rounded-none group/tabs-list inline-flex w-fit items-center justify-center text-muted-foreground group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col',
{
variants: {
variant: {
default: 'bg-muted',
line: 'gap-1 bg-transparent',
},
},
defaultVariants: {
variant: 'default',
},
},
)
export type TabsListVariants = VariantProps<typeof tabsListVariants>
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { TooltipRootEmits, TooltipRootProps } from 'reka-ui'
import { TooltipRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<TooltipRootProps>()
const emits = defineEmits<TooltipRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<TooltipRoot
v-slot="slotProps"
data-slot="tooltip"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</TooltipRoot>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { TooltipContentEmits, TooltipContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { TooltipArrow, TooltipContent, TooltipPortal, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<TooltipContentProps & { class?: HTMLAttributes['class'] }>(), {
sideOffset: 0,
})
const emits = defineEmits<TooltipContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<TooltipPortal>
<TooltipContent
data-slot="tooltip-content"
v-bind="{ ...forwarded, ...$attrs }"
:class="cn('data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-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 inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs has-data-[slot=kbd]:pr-1.5 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm bg-foreground text-background z-50 w-fit max-w-xs origin-(--reka-tooltip-content-transform-origin)', props.class)"
>
<slot />
<TooltipArrow class="size-2.5 rotate-45 rounded-[2px] bg-foreground fill-foreground z-50 translate-y-[calc(-50%_-_2px)]" />
</TooltipContent>
</TooltipPortal>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { TooltipProviderProps } from 'reka-ui'
import { TooltipProvider } from 'reka-ui'
const props = withDefaults(defineProps<TooltipProviderProps>(), {
delayDuration: 0,
})
</script>
<template>
<TooltipProvider v-bind="props">
<slot />
</TooltipProvider>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { TooltipTriggerProps } from 'reka-ui'
import { TooltipTrigger } from 'reka-ui'
const props = defineProps<TooltipTriggerProps>()
</script>
<template>
<TooltipTrigger
data-slot="tooltip-trigger"
v-bind="props"
>
<slot />
</TooltipTrigger>
</template>
+4
View File
@@ -0,0 +1,4 @@
export { default as Tooltip } from './Tooltip.vue'
export { default as TooltipContent } from './TooltipContent.vue'
export { default as TooltipProvider } from './TooltipProvider.vue'
export { default as TooltipTrigger } from './TooltipTrigger.vue'
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
+28
View File
@@ -0,0 +1,28 @@
import { createI18n } from "vue-i18n";
import en from "./locales/en";
import zhCN from "./locales/zh-CN";
export type Locale = "en" | "zh-CN";
const savedLocale = (localStorage.getItem("dbx-locale") as Locale) || "zh-CN";
const i18n = createI18n({
legacy: false,
locale: savedLocale,
fallbackLocale: "en",
messages: {
en,
"zh-CN": zhCN,
},
});
export function setLocale(locale: Locale) {
i18n.global.locale.value = locale;
localStorage.setItem("dbx-locale", locale);
}
export function currentLocale(): Locale {
return i18n.global.locale.value as Locale;
}
export default i18n;
+121
View File
@@ -0,0 +1,121 @@
export default {
app: {
name: "DBX",
},
toolbar: {
newConnection: "New Connection",
newQuery: "New Query",
execute: "Execute",
executeShortcut: "Execute (Cmd+Enter)",
},
sidebar: {
connections: "CONNECTIONS",
noConnections: "No connections yet",
import: "Import Connections",
export: "Export Connections",
showMore: "Show {count} more...",
},
connection: {
title: "New Connection",
name: "Name",
namePlaceholder: "My Database",
type: "Type",
host: "Host",
filePath: "File Path",
user: "User",
password: "Password",
database: "Database",
databasePlaceholder: "Optional",
test: "Test",
testing: "Testing...",
saveAndConnect: "Save & Connect",
save: "Save",
editTitle: "Edit Connection",
testSuccess: "Connection successful",
sshTunnel: "SSH Tunnel",
sshEnable: "Connect via SSH tunnel",
sshHost: "SSH Host",
sshUser: "SSH User",
sshKeyPath: "Key Path",
},
editor: {
pressToExecute: "Press Cmd+Enter to execute",
},
grid: {
rows: "{count} rows",
rowsAffected: "{count} rows affected",
querySuccess: "Query executed successfully",
noRows: "No data",
noRowsDescription: "This result set has no records.",
noSearchResults: "No matches",
noSearchResultsDescription: "Try a different keyword.",
copyCell: "Copy Cell",
copyRow: "Copy Row (JSON)",
copyAll: "Copy All (TSV)",
exportCsv: "Export CSV",
exportJson: "Export JSON",
exportMarkdown: "Export Markdown",
search: "Search...",
page: "Page {page}",
rowsPerPage: "Rows per page",
save: "Save",
discard: "Discard",
addRow: "Add Row",
deleteRow: "Delete Row",
rowsPerPageShort: " rows",
},
welcome: {
title: "Welcome to DBX",
subtitle: "Create a connection to get started",
},
common: {
language: "Language",
loading: "Loading...",
},
ai: {
placeholder: "Describe your query in natural language...",
settings: "AI Settings",
provider: "Provider",
},
contextMenu: {
openConnection: "Open Connection",
closeConnection: "Close Connection",
deleteConnection: "Delete Connection",
editConnection: "Edit Connection",
newQuery: "New Query",
viewData: "View Data",
refreshChildren: "Refresh",
copyName: "Copy Name",
},
tree: {
columns: "Columns",
indexes: "Indexes",
foreignKeys: "Foreign Keys",
triggers: "Triggers",
},
redis: {
selectKey: "Select a key to view its value",
noKeys: "No keys found",
pattern: "pattern (e.g. user:*)",
keys: "{count} keys",
items: "{count} items",
fields: "{count} fields",
members: "{count} members",
noExpiry: "no expiry",
},
history: {
title: "History",
search: "Search history...",
empty: "No history yet",
failed: "Failed",
restore: "Restore to editor",
copy: "Copy SQL",
delete: "Delete",
},
dangerDialog: {
title: "Dangerous Operation",
message: "This SQL statement may modify or delete data irreversibly. Are you sure you want to execute it?",
cancel: "Cancel",
confirm: "Execute",
},
};
+121
View File
@@ -0,0 +1,121 @@
export default {
app: {
name: "DBX",
},
toolbar: {
newConnection: "新建连接",
newQuery: "新建查询",
execute: "执行",
executeShortcut: "执行 (Cmd+Enter)",
},
sidebar: {
connections: "连接",
noConnections: "暂无连接",
import: "导入连接",
export: "导出连接",
showMore: "加载更多 ({count})...",
},
connection: {
title: "新建连接",
name: "名称",
namePlaceholder: "我的数据库",
type: "类型",
host: "主机",
filePath: "文件路径",
user: "用户名",
password: "密码",
database: "数据库",
databasePlaceholder: "可选",
test: "测试",
testing: "测试中...",
saveAndConnect: "保存并连接",
save: "保存",
editTitle: "编辑连接",
testSuccess: "连接成功",
sshTunnel: "SSH 隧道",
sshEnable: "通过 SSH 隧道连接",
sshHost: "SSH 主机",
sshUser: "SSH 用户",
sshKeyPath: "密钥路径",
},
editor: {
pressToExecute: "按 Cmd+Enter 执行查询",
},
grid: {
rows: "{count} 行",
rowsAffected: "影响 {count} 行",
querySuccess: "查询执行成功",
noRows: "暂无数据",
noRowsDescription: "当前结果集没有任何记录。",
noSearchResults: "没有匹配结果",
noSearchResultsDescription: "换个关键词试试。",
copyCell: "复制单元格",
copyRow: "复制行 (JSON)",
copyAll: "复制全部 (TSV)",
exportCsv: "导出 CSV",
exportJson: "导出 JSON",
exportMarkdown: "导出 Markdown",
search: "搜索...",
page: "第 {page} 页",
rowsPerPage: "每页行数",
save: "保存",
discard: "放弃",
addRow: "新增行",
deleteRow: "删除行",
rowsPerPageShort: " 行/页",
},
welcome: {
title: "欢迎使用 DBX",
subtitle: "创建一个连接以开始使用",
},
common: {
language: "语言",
loading: "加载中...",
},
ai: {
placeholder: "用自然语言描述你的查询...",
settings: "AI 设置",
provider: "提供商",
},
contextMenu: {
openConnection: "打开连接",
closeConnection: "关闭连接",
deleteConnection: "删除连接",
editConnection: "编辑连接",
newQuery: "新建查询",
viewData: "查看数据",
refreshChildren: "刷新",
copyName: "复制名称",
},
tree: {
columns: "字段",
indexes: "索引",
foreignKeys: "外键",
triggers: "触发器",
},
redis: {
selectKey: "选择一个 key 查看值",
noKeys: "未找到 key",
pattern: "匹配模式 (如 user:*)",
keys: "{count} 个 key",
items: "{count} 个元素",
fields: "{count} 个字段",
members: "{count} 个成员",
noExpiry: "永不过期",
},
history: {
title: "查询历史",
search: "搜索历史...",
empty: "暂无历史记录",
failed: "失败",
restore: "恢复到编辑器",
copy: "复制 SQL",
delete: "删除",
},
dangerDialog: {
title: "危险操作",
message: "此 SQL 语句可能不可逆地修改或删除数据,确认要执行吗?",
cancel: "取消",
confirm: "执行",
},
};
+50
View File
@@ -0,0 +1,50 @@
import type { AiConfig } from "@/stores/settingsStore";
export async function generateSql(
config: AiConfig,
prompt: string,
tableContext: string,
): Promise<string> {
const systemPrompt = `You are a SQL expert assistant. Given the database schema below, generate a SQL query based on the user's request. Return ONLY the SQL query, no explanations.\n\nSchema:\n${tableContext}`;
if (config.provider === "claude") {
const res = await fetch(config.endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": config.apiKey,
"anthropic-version": "2023-06-01",
"anthropic-dangerous-direct-browser-access": "true",
},
body: JSON.stringify({
model: config.model,
max_tokens: 2048,
system: systemPrompt,
messages: [{ role: "user", content: prompt }],
}),
});
if (!res.ok) throw new Error(`Claude API error: ${res.status}`);
const data = await res.json();
return data.content?.[0]?.text || "";
}
// OpenAI-compatible (works for OpenAI, custom endpoints)
const res = await fetch(config.endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${config.apiKey}`,
},
body: JSON.stringify({
model: config.model,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: prompt },
],
max_tokens: 2048,
}),
});
if (!res.ok) throw new Error(`API error: ${res.status}`);
const data = await res.json();
return data.choices?.[0]?.message?.content || "";
}
+184
View File
@@ -0,0 +1,184 @@
import { invoke } from "@tauri-apps/api/core";
import type {
ConnectionConfig,
DatabaseInfo,
TableInfo,
ColumnInfo,
IndexInfo,
ForeignKeyInfo,
TriggerInfo,
QueryResult,
} from "@/types/database";
export async function testConnection(config: ConnectionConfig): Promise<string> {
return invoke("test_connection", { config });
}
export async function connectDb(config: ConnectionConfig): Promise<string> {
return invoke("connect_db", { config });
}
export async function disconnectDb(connectionId: string): Promise<void> {
return invoke("disconnect_db", { connectionId });
}
export async function listDatabases(connectionId: string): Promise<DatabaseInfo[]> {
return invoke("list_databases", { connectionId });
}
export async function listTables(connectionId: string, database: string, schema: string): Promise<TableInfo[]> {
return invoke("list_tables", { connectionId, database, schema });
}
export async function listSchemas(connectionId: string, database: string): Promise<string[]> {
return invoke("list_schemas", { connectionId, database });
}
export async function getColumns(
connectionId: string,
database: string,
schema: string,
table: string,
): Promise<ColumnInfo[]> {
return invoke("get_columns", { connectionId, database, schema, table });
}
export async function executeQuery(connectionId: string, database: string, sql: string): Promise<QueryResult> {
return invoke("execute_query", { connectionId, database, sql });
}
export async function listIndexes(connectionId: string, database: string, schema: string, table: string): Promise<IndexInfo[]> {
return invoke("list_indexes", { connectionId, database, schema, table });
}
export async function listForeignKeys(connectionId: string, database: string, schema: string, table: string): Promise<ForeignKeyInfo[]> {
return invoke("list_foreign_keys", { connectionId, database, schema, table });
}
export async function listTriggers(connectionId: string, database: string, schema: string, table: string): Promise<TriggerInfo[]> {
return invoke("list_triggers", { connectionId, database, schema, table });
}
export async function saveConnections(configs: ConnectionConfig[]): Promise<void> {
return invoke("save_connections", { configs });
}
export async function loadConnections(): Promise<ConnectionConfig[]> {
return invoke("load_connections");
}
// --- Redis ---
export interface RedisKeyInfo {
key: string;
key_type: string;
ttl: number;
}
export interface RedisValue {
key: string;
key_type: string;
ttl: number;
value: any;
}
export async function redisListDatabases(connectionId: string): Promise<number[]> {
return invoke("redis_list_databases", { connectionId });
}
export async function redisScanKeys(connectionId: string, db: number, pattern: string, count: number): Promise<RedisKeyInfo[]> {
return invoke("redis_scan_keys", { connectionId, db, pattern, count });
}
export async function redisGetValue(connectionId: string, key: string): Promise<RedisValue> {
return invoke("redis_get_value", { connectionId, key });
}
export async function redisSetString(connectionId: string, key: string, value: string, ttl?: number): Promise<void> {
return invoke("redis_set_string", { connectionId, key, value, ttl });
}
export async function redisDeleteKey(connectionId: string, key: string): Promise<void> {
return invoke("redis_delete_key", { connectionId, key });
}
export async function redisHashSet(connectionId: string, key: string, field: string, value: string): Promise<void> {
return invoke("redis_hash_set", { connectionId, key, field, value });
}
export async function redisHashDel(connectionId: string, key: string, field: string): Promise<void> {
return invoke("redis_hash_del", { connectionId, key, field });
}
export async function redisListPush(connectionId: string, key: string, value: string): Promise<void> {
return invoke("redis_list_push", { connectionId, key, value });
}
export async function redisListRemove(connectionId: string, key: string, index: number): Promise<void> {
return invoke("redis_list_remove", { connectionId, key, index });
}
export async function redisSetAdd(connectionId: string, key: string, member: string): Promise<void> {
return invoke("redis_set_add", { connectionId, key, member });
}
export async function redisSetRemove(connectionId: string, key: string, member: string): Promise<void> {
return invoke("redis_set_remove", { connectionId, key, member });
}
// --- MongoDB ---
export interface MongoDocumentResult {
documents: any[];
total: number;
}
export async function mongoListDatabases(connectionId: string): Promise<string[]> {
return invoke("mongo_list_databases", { connectionId });
}
export async function mongoListCollections(connectionId: string, database: string): Promise<string[]> {
return invoke("mongo_list_collections", { connectionId, database });
}
export async function mongoFindDocuments(connectionId: string, database: string, collection: string, skip: number, limit: number): Promise<MongoDocumentResult> {
return invoke("mongo_find_documents", { connectionId, database, collection, skip, limit });
}
export async function mongoInsertDocument(connectionId: string, database: string, collection: string, docJson: string): Promise<string> {
return invoke("mongo_insert_document", { connectionId, database, collection, docJson });
}
export async function mongoUpdateDocument(connectionId: string, database: string, collection: string, id: string, docJson: string): Promise<number> {
return invoke("mongo_update_document", { connectionId, database, collection, id, docJson });
}
export async function mongoDeleteDocument(connectionId: string, database: string, collection: string, id: string): Promise<number> {
return invoke("mongo_delete_document", { connectionId, database, collection, id });
}
// --- History ---
export interface HistoryEntry {
id: string;
connection_name: string;
database: string;
sql: string;
executed_at: string;
execution_time_ms: number;
success: boolean;
error?: string;
}
export async function saveHistory(entry: HistoryEntry): Promise<void> {
return invoke("save_history", { entry });
}
export async function loadHistory(limit: number, offset: number): Promise<HistoryEntry[]> {
return invoke("load_history", { limit, offset });
}
export async function clearHistory(): Promise<void> {
return invoke("clear_history");
}
export async function deleteHistoryEntry(id: string): Promise<void> {
return invoke("delete_history_entry", { id });
}
+7
View File
@@ -0,0 +1,7 @@
import type { ClassValue } from "clsx"
import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+13
View File
@@ -0,0 +1,13 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import VueVirtualScroller from "vue-virtual-scroller";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
import i18n from "./i18n";
import App from "./App.vue";
import "./styles/globals.css";
const app = createApp(App);
app.use(createPinia());
app.use(i18n);
app.use(VueVirtualScroller);
app.mount("#root");
+465
View File
@@ -0,0 +1,465 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import type { ConnectionConfig, TreeNode } from "@/types/database";
import * as api from "@/lib/tauri";
export const useConnectionStore = defineStore("connection", () => {
const connections = ref<ConnectionConfig[]>([]);
const activeConnectionId = ref<string | null>(null);
const treeNodes = ref<TreeNode[]>([]);
const connectedIds = ref<Set<string>>(new Set());
const editingConnectionId = ref<string | null>(null);
function startEditing(id: string) {
editingConnectionId.value = id;
}
function stopEditing() {
editingConnectionId.value = null;
}
function getConfig(connectionId: string) {
return connections.value.find((c) => c.id === connectionId);
}
function addConnection(config: ConnectionConfig) {
connections.value.push(config);
persistConnections();
}
function removeConnection(id: string) {
connections.value = connections.value.filter((c) => c.id !== id);
treeNodes.value = treeNodes.value.filter((n) => n.id !== id);
if (activeConnectionId.value === id) {
activeConnectionId.value = null;
}
persistConnections();
}
function updateConnection(config: ConnectionConfig) {
const idx = connections.value.findIndex((c) => c.id === config.id);
if (idx >= 0) connections.value[idx] = config;
const node = findNode(treeNodes.value, config.id);
if (node) {
node.label = config.name;
node.isExpanded = false;
node.children = [];
}
connectedIds.value.delete(config.id);
persistConnections();
}
async function connect(config: ConnectionConfig) {
const id = await api.connectDb(config);
activeConnectionId.value = id;
connectedIds.value.add(id);
const node: TreeNode = {
id,
label: config.name,
type: "connection",
connectionId: id,
isExpanded: false,
children: [],
};
const existing = treeNodes.value.findIndex((n) => n.id === id);
if (existing >= 0) {
treeNodes.value[existing] = node;
} else {
treeNodes.value.push(node);
}
return id;
}
async function disconnect(connectionId: string) {
await api.disconnectDb(connectionId);
connectedIds.value.delete(connectionId);
treeNodes.value = treeNodes.value.filter((n) => n.id !== connectionId);
if (activeConnectionId.value === connectionId) {
activeConnectionId.value = null;
}
}
async function ensureConnected(connectionId: string) {
if (connectedIds.value.has(connectionId)) return;
const config = getConfig(connectionId);
if (!config) throw new Error("Connection config not found");
await api.connectDb(config);
connectedIds.value.add(connectionId);
activeConnectionId.value = connectionId;
}
async function loadDatabases(connectionId: string) {
const node = findNode(treeNodes.value, connectionId);
if (!node) return;
node.isLoading = true;
try {
await ensureConnected(connectionId);
const databases = await api.listDatabases(connectionId);
node.children = databases.map((db) => ({
id: `${connectionId}:${db.name}`,
label: db.name,
type: "database" as const,
connectionId,
database: db.name,
isExpanded: false,
children: [],
}));
node.isExpanded = true;
} finally {
node.isLoading = false;
}
}
async function loadRedisDatabases(connectionId: string) {
const node = findNode(treeNodes.value, connectionId);
if (!node) return;
node.isLoading = true;
try {
await ensureConnected(connectionId);
const dbs = await api.redisListDatabases(connectionId);
node.children = dbs.map((db) => ({
id: `${connectionId}:db${db}`,
label: `db${db}`,
type: "redis-db" as const,
connectionId,
database: String(db),
isExpanded: false,
children: [],
}));
node.isExpanded = true;
} finally {
node.isLoading = false;
}
}
async function loadRedisKeys(connectionId: string, db: number, nodeId: string) {
const node = findNode(treeNodes.value, nodeId);
if (!node) return;
node.isLoading = true;
try {
const keys = await api.redisScanKeys(connectionId, db, "*", 500);
node.children = keys.map((k) => ({
id: `${nodeId}:${k.key}`,
label: `${k.key} [${k.key_type}]${k.ttl > 0 ? ` TTL:${k.ttl}` : ""}`,
type: "redis-key" as const,
connectionId,
database: String(db),
isExpanded: false,
}));
node.isExpanded = true;
} finally {
node.isLoading = false;
}
}
async function loadMongoDatabases(connectionId: string) {
const node = findNode(treeNodes.value, connectionId);
if (!node) return;
node.isLoading = true;
try {
await ensureConnected(connectionId);
const dbs = await api.mongoListDatabases(connectionId);
node.children = dbs.map((db) => ({
id: `${connectionId}:${db}`,
label: db,
type: "mongo-db" as const,
connectionId,
database: db,
isExpanded: false,
children: [],
}));
node.isExpanded = true;
} finally {
node.isLoading = false;
}
}
async function loadMongoCollections(connectionId: string, database: string) {
const nodeId = `${connectionId}:${database}`;
const node = findNode(treeNodes.value, nodeId);
if (!node) return;
node.isLoading = true;
try {
const collections = await api.mongoListCollections(connectionId, database);
node.children = collections.map((col) => ({
id: `${nodeId}:${col}`,
label: col,
type: "mongo-collection" as const,
connectionId,
database,
isExpanded: false,
}));
node.isExpanded = true;
} finally {
node.isLoading = false;
}
}
async function loadSchemas(connectionId: string, database: string) {
const nodeId = `${connectionId}:${database}`;
const node = findNode(treeNodes.value, nodeId);
if (!node) return;
node.isLoading = true;
try {
const schemas = await api.listSchemas(connectionId, database);
node.children = schemas.map((s) => ({
id: `${connectionId}:${database}:${s}`,
label: s,
type: "schema" as const,
connectionId,
database,
schema: s,
isExpanded: false,
children: [],
}));
node.isExpanded = true;
} finally {
node.isLoading = false;
}
}
async function loadTables(connectionId: string, database: string, schema?: string) {
const nodeId = schema
? `${connectionId}:${database}:${schema}`
: `${connectionId}:${database}`;
const node = findNode(treeNodes.value, nodeId);
if (!node) return;
node.isLoading = true;
try {
const querySchema = schema || database;
const tables = await api.listTables(connectionId, database, querySchema);
node.children = tables.map((t) => ({
id: `${nodeId}:${t.name}`,
label: t.name,
type: (t.table_type === "VIEW" ? "view" : "table") as "view" | "table",
connectionId,
database,
schema,
isExpanded: false,
children: [],
}));
node.isExpanded = true;
} finally {
node.isLoading = false;
}
}
async function loadTableGroups(connectionId: string, database: string, table: string, schema?: string) {
const parentId = schema
? `${connectionId}:${database}:${schema}:${table}`
: `${connectionId}:${database}:${table}`;
const node = findNode(treeNodes.value, parentId);
if (!node) return;
node.children = [
{ id: `${parentId}:__columns`, label: "tree.columns", type: "group-columns", connectionId, database, schema, tableName: table, isExpanded: false, children: [] },
{ id: `${parentId}:__indexes`, label: "tree.indexes", type: "group-indexes", connectionId, database, schema, tableName: table, isExpanded: false, children: [] },
{ id: `${parentId}:__fkeys`, label: "tree.foreignKeys", type: "group-fkeys", connectionId, database, schema, tableName: table, isExpanded: false, children: [] },
{ id: `${parentId}:__triggers`, label: "tree.triggers", type: "group-triggers", connectionId, database, schema, tableName: table, isExpanded: false, children: [] },
];
node.isExpanded = true;
}
async function loadColumns(connectionId: string, database: string, table: string, schema?: string) {
const parentId = schema
? `${connectionId}:${database}:${schema}:${table}:__columns`
: `${connectionId}:${database}:${table}:__columns`;
const node = findNode(treeNodes.value, parentId);
if (!node) return;
node.isLoading = true;
try {
const querySchema = schema || database;
const columns = await api.getColumns(connectionId, database, querySchema, table);
node.children = columns.map((col) => ({
id: `${parentId}:${col.name}`,
label: `${col.name} (${col.data_type})`,
type: "column" as const,
connectionId,
database,
schema,
meta: col,
}));
node.isExpanded = true;
} finally {
node.isLoading = false;
}
}
async function loadIndexes(connectionId: string, database: string, table: string, schema?: string) {
const parentId = schema
? `${connectionId}:${database}:${schema}:${table}:__indexes`
: `${connectionId}:${database}:${table}:__indexes`;
const node = findNode(treeNodes.value, parentId);
if (!node) return;
node.isLoading = true;
try {
const querySchema = schema || database;
const indexes = await api.listIndexes(connectionId, database, querySchema, table);
node.children = indexes.map((idx) => ({
id: `${parentId}:${idx.name}`,
label: `${idx.name} (${idx.columns.join(", ")})`,
type: "index" as const,
connectionId,
database,
schema,
meta: idx,
}));
node.isExpanded = true;
} finally {
node.isLoading = false;
}
}
async function loadForeignKeys(connectionId: string, database: string, table: string, schema?: string) {
const parentId = schema
? `${connectionId}:${database}:${schema}:${table}:__fkeys`
: `${connectionId}:${database}:${table}:__fkeys`;
const node = findNode(treeNodes.value, parentId);
if (!node) return;
node.isLoading = true;
try {
const querySchema = schema || database;
const fkeys = await api.listForeignKeys(connectionId, database, querySchema, table);
node.children = fkeys.map((fk) => ({
id: `${parentId}:${fk.name}`,
label: `${fk.column} → ${fk.ref_table}.${fk.ref_column}`,
type: "fkey" as const,
connectionId,
database,
schema,
meta: fk,
}));
node.isExpanded = true;
} finally {
node.isLoading = false;
}
}
async function loadTriggers(connectionId: string, database: string, table: string, schema?: string) {
const parentId = schema
? `${connectionId}:${database}:${schema}:${table}:__triggers`
: `${connectionId}:${database}:${table}:__triggers`;
const node = findNode(treeNodes.value, parentId);
if (!node) return;
node.isLoading = true;
try {
const querySchema = schema || database;
const triggers = await api.listTriggers(connectionId, database, querySchema, table);
node.children = triggers.map((tr) => ({
id: `${parentId}:${tr.name}`,
label: `${tr.name} (${tr.timing} ${tr.event})`,
type: "trigger" as const,
connectionId,
database,
schema,
meta: tr,
}));
node.isExpanded = true;
} finally {
node.isLoading = false;
}
}
function findNode(nodes: TreeNode[], id: string): TreeNode | null {
for (const node of nodes) {
if (node.id === id) return node;
if (node.children) {
const found = findNode(node.children, id);
if (found) return found;
}
}
return null;
}
function persistConnections() {
api.saveConnections(connections.value).catch(() => {});
}
async function exportConnectionsToFile() {
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
const path = await save({ filters: [{ name: "JSON", extensions: ["json"] }], defaultPath: "dbx-connections.json" });
if (!path) return;
const data = connections.value.map((c) => ({ ...c, password: "" }));
await writeTextFile(path, JSON.stringify(data, null, 2));
}
async function importConnectionsFromFile() {
const { open } = await import("@tauri-apps/plugin-dialog");
const { readTextFile } = await import("@tauri-apps/plugin-fs");
const path = await open({ filters: [{ name: "JSON", extensions: ["json"] }], multiple: false });
if (!path) return;
const json = await readTextFile(path as string);
const imported: ConnectionConfig[] = JSON.parse(json);
for (const config of imported) {
if (!connections.value.find((c) => c.id === config.id)) {
config.id = crypto.randomUUID();
addConnection(config);
treeNodes.value.push({
id: config.id,
label: config.name,
type: "connection" as const,
connectionId: config.id,
isExpanded: false,
children: [],
});
}
}
}
async function initFromDisk() {
const saved = await api.loadConnections();
connections.value = saved;
treeNodes.value = saved.map((config) => ({
id: config.id,
label: config.name,
type: "connection" as const,
connectionId: config.id,
isExpanded: false,
children: [],
}));
}
return {
connections,
activeConnectionId,
treeNodes,
getConfig,
addConnection,
updateConnection,
removeConnection,
editingConnectionId,
startEditing,
stopEditing,
connect,
disconnect,
ensureConnected,
initFromDisk,
loadDatabases,
loadRedisDatabases,
loadRedisKeys,
loadMongoDatabases,
loadMongoCollections,
loadSchemas,
loadTables,
loadTableGroups,
loadColumns,
loadIndexes,
loadForeignKeys,
loadTriggers,
exportConnectionsToFile,
importConnectionsFromFile,
};
});
+41
View File
@@ -0,0 +1,41 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import * as api from "@/lib/tauri";
import type { HistoryEntry } from "@/lib/tauri";
export const useHistoryStore = defineStore("history", () => {
const entries = ref<HistoryEntry[]>([]);
const loading = ref(false);
async function load() {
loading.value = true;
try {
entries.value = await api.loadHistory(200, 0);
} finally {
loading.value = false;
}
}
async function add(entry: Omit<HistoryEntry, "id" | "executed_at">) {
const full: HistoryEntry = {
...entry,
id: crypto.randomUUID(),
executed_at: new Date().toISOString(),
};
await api.saveHistory(full);
entries.value.unshift(full);
if (entries.value.length > 200) entries.value.pop();
}
async function remove(id: string) {
await api.deleteHistoryEntry(id);
entries.value = entries.value.filter((e) => e.id !== id);
}
async function clear() {
await api.clearHistory();
entries.value = [];
}
return { entries, loading, load, add, remove, clear };
});

Some files were not shown because too many files have changed in this diff Show More