216 lines
7.0 KiB
Vue
216 lines
7.0 KiB
Vue
<script setup lang="ts">
|
|
import { computed, ref, watch, nextTick } from "vue";
|
|
import { useI18n } from "vue-i18n";
|
|
import { Command, FileCode, FileText } from "@lucide/vue";
|
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { useQuickOpen, type QuickOpenItem } from "@/composables/useQuickOpen";
|
|
|
|
const props = defineProps<{
|
|
open: boolean;
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
"update:open": [value: boolean];
|
|
select: [item: QuickOpenItem];
|
|
}>();
|
|
|
|
const { t } = useI18n();
|
|
const { searchQuery, filteredItems, selectedIndex, selectedItem, selectNext, selectPrevious, setQuery, loadExternalSqlFiles } = useQuickOpen();
|
|
const inputRef = ref<HTMLInputElement | null>(null);
|
|
|
|
const dialogOpen = computed({
|
|
get: () => props.open,
|
|
set: (value) => emit("update:open", value),
|
|
});
|
|
|
|
function handleKeyDown(e: KeyboardEvent): void {
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
selectNext();
|
|
} else if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
selectPrevious();
|
|
} else if (e.key === "Enter" && selectedItem.value) {
|
|
e.preventDefault();
|
|
handleSelect(selectedItem.value);
|
|
} else if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
dialogOpen.value = false;
|
|
}
|
|
}
|
|
|
|
function handleSelect(item: QuickOpenItem): void {
|
|
emit("select", item);
|
|
dialogOpen.value = false;
|
|
}
|
|
|
|
function getHighlightedLabel(item: any): (string | { text: string; highlight: boolean })[] {
|
|
if (!searchQuery.value.trim() || !item.matchIndices) {
|
|
return [item.label];
|
|
}
|
|
|
|
const indices = new Set(item.matchIndices);
|
|
const parts: (string | { text: string; highlight: boolean })[] = [];
|
|
let current = "";
|
|
let isHighlighting = false;
|
|
|
|
for (let i = 0; i < item.label.length; i++) {
|
|
const char = item.label[i];
|
|
const shouldHighlight = indices.has(i);
|
|
|
|
if (shouldHighlight !== isHighlighting) {
|
|
if (current) {
|
|
parts.push({
|
|
text: current,
|
|
highlight: isHighlighting,
|
|
});
|
|
}
|
|
current = char;
|
|
isHighlighting = shouldHighlight;
|
|
} else {
|
|
current += char;
|
|
}
|
|
}
|
|
|
|
if (current) {
|
|
parts.push({
|
|
text: current,
|
|
highlight: isHighlighting,
|
|
});
|
|
}
|
|
|
|
return parts;
|
|
}
|
|
|
|
function getTypeLabel(type: string): string {
|
|
switch (type) {
|
|
case "connection":
|
|
return t("common.connection");
|
|
case "database":
|
|
return t("common.database");
|
|
case "schema":
|
|
return t("common.schema");
|
|
case "table":
|
|
return t("common.table");
|
|
case "view":
|
|
return t("common.view");
|
|
case "materialized_view":
|
|
return t("common.materializedView");
|
|
case "procedure":
|
|
return t("common.procedure");
|
|
case "function":
|
|
return t("common.function");
|
|
case "sequence":
|
|
return t("common.sequence");
|
|
case "package":
|
|
return t("common.package");
|
|
case "package-body":
|
|
return t("common.packageBody");
|
|
case "sql_file":
|
|
return t("quickOpen.sqlFile");
|
|
case "sql_library_file":
|
|
return t("quickOpen.sqlLibraryFile");
|
|
default:
|
|
return type;
|
|
}
|
|
}
|
|
|
|
function getItemIcon(type: string) {
|
|
if (type === "sql_file") return FileCode;
|
|
if (type === "sql_library_file") return FileText;
|
|
return null;
|
|
}
|
|
|
|
watch(
|
|
() => props.open,
|
|
(newOpen) => {
|
|
if (newOpen) {
|
|
setQuery("");
|
|
// Eagerly load external SQL files so they appear in the initial list
|
|
void loadExternalSqlFiles();
|
|
nextTick(() => {
|
|
inputRef.value?.focus();
|
|
});
|
|
}
|
|
},
|
|
);
|
|
</script>
|
|
|
|
<template>
|
|
<Dialog :open="dialogOpen" @update:open="dialogOpen = $event">
|
|
<DialogContent class="max-w-2xl p-0 gap-0 rounded-lg overflow-hidden">
|
|
<div class="flex flex-col bg-background">
|
|
<!-- Search Input -->
|
|
<div class="flex items-center gap-3 px-4 py-3 border-b">
|
|
<Command class="h-5 w-5 text-muted-foreground" />
|
|
<Input ref="inputRef" v-model="searchQuery" type="text" :placeholder="t('quickOpen.placeholder')" class="flex-1 border-0 bg-transparent p-0 placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:outline-none" @keydown="handleKeyDown" />
|
|
</div>
|
|
|
|
<!-- Results List -->
|
|
<div class="max-h-[400px] overflow-y-auto">
|
|
<div v-if="filteredItems.length === 0" class="px-4 py-8 text-center text-muted-foreground">
|
|
<p v-if="!searchQuery.trim()">{{ t("quickOpen.emptyPlaceholder") }}</p>
|
|
<p v-else>{{ t("quickOpen.noResults") }}</p>
|
|
</div>
|
|
|
|
<div v-else class="divide-y">
|
|
<div v-for="(item, index) in filteredItems" :key="item.id" :class="['px-4 py-2 cursor-pointer', index === selectedIndex ? 'bg-accent' : 'hover:bg-muted']" @click="handleSelect(item)" @mouseenter="selectedIndex = index">
|
|
<div class="flex items-center justify-between gap-3">
|
|
<div class="flex items-center gap-2 flex-1 min-w-0">
|
|
<component v-if="getItemIcon(item.type)" :is="getItemIcon(item.type)" class="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
<div class="flex-1 min-w-0">
|
|
<div class="text-sm font-medium truncate">
|
|
<template v-for="(part, i) in getHighlightedLabel(item)" :key="i">
|
|
<span v-if="typeof part === 'object'" :class="{ 'bg-yellow-200 dark:bg-yellow-800 font-semibold': part.highlight }">
|
|
{{ part.text }}
|
|
</span>
|
|
<span v-else>{{ part }}</span>
|
|
</template>
|
|
</div>
|
|
<div v-if="item.description" class="text-xs text-muted-foreground truncate">
|
|
{{ item.description }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="text-xs px-2 py-1 rounded bg-muted text-muted-foreground whitespace-nowrap">
|
|
{{ getTypeLabel(item.type) }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<div class="px-4 py-2 border-t text-xs text-muted-foreground flex justify-between">
|
|
<div>{{ filteredItems.length }} {{ t("quickOpen.results") }}</div>
|
|
<div class="flex gap-4">
|
|
<span><kbd class="px-2 py-1 rounded bg-muted">↑↓</kbd> {{ t("quickOpen.navigate") }}</span>
|
|
<span><kbd class="px-2 py-1 rounded bg-muted">⏎</kbd> {{ t("quickOpen.select") }}</span>
|
|
<span><kbd class="px-2 py-1 rounded bg-muted">ESC</kbd> {{ t("quickOpen.close") }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</template>
|
|
|
|
<style scoped>
|
|
:deep([data-slot="dialog-content"]) {
|
|
border-color: color-mix(in srgb, var(--border) 80%, var(--ring));
|
|
box-shadow: 0 24px 70px rgb(0 0 0 / 0.32);
|
|
}
|
|
|
|
:deep(.divide-y > div.bg-accent) {
|
|
background-color: var(--info-bg) !important;
|
|
box-shadow: inset 3px 0 0 var(--info) !important;
|
|
}
|
|
|
|
:deep(.bg-yellow-200),
|
|
:deep(.dark .bg-yellow-800) {
|
|
background-color: var(--warning-bg) !important;
|
|
border-radius: 0.1875rem;
|
|
padding: 0 0.0625rem;
|
|
}
|
|
</style>
|