mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(sql-files): locate files and preview or edit search matches
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, onMounted } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { FolderOpen, FolderClosed, FileCode, FolderPlus, ChevronRight, ChevronDown, X, Trash2, RefreshCw, FolderSearch, Copy, Play, ChevronsUpDown, ChevronsDownUp, Settings, FilePlus, Pencil, LocateFixed } from "@lucide/vue";
|
||||
import { FolderOpen, FolderClosed, FileCode, FolderPlus, ChevronRight, ChevronDown, X, Trash2, RefreshCw, FolderSearch, Copy, Play, ChevronsUpDown, ChevronsDownUp, Settings, FilePlus, Pencil } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import LightTooltip from "@/components/ui/LightTooltip.vue";
|
||||
import HelpTooltip from "@/components/ui/tooltip/HelpTooltip.vue";
|
||||
import SidebarLocateButton from "@/components/sidebar/SidebarLocateButton.vue";
|
||||
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
@@ -42,6 +43,12 @@ interface FolderState {
|
||||
}
|
||||
|
||||
const folders = ref<FolderState[]>([]);
|
||||
const fileTree = ref<HTMLElement | null>(null);
|
||||
const locatingFile = ref(false);
|
||||
const currentFilePath = computed(() => {
|
||||
const tab = queryStore.tabs.find((tab) => tab.id === queryStore.activeTabId);
|
||||
return tab?.mode === "query" ? tab.externalSqlPath : undefined;
|
||||
});
|
||||
const filterSettingsOpen = ref(false);
|
||||
const fileFilterDraft = ref(getSqlFileFilter());
|
||||
const fileFilterRegexExample = "(?i)[.](sql|ddl|sh|py)$";
|
||||
@@ -69,7 +76,6 @@ const showDeleteFolderDialog = ref(false);
|
||||
const selectedPaths = ref<Set<string>>(new Set());
|
||||
const activePath = ref<string | null>(null);
|
||||
const selectionAnchorIndex = ref<number | null>(null);
|
||||
const fileTree = ref<HTMLElement | null>(null);
|
||||
|
||||
function findFileAncestors(entries: SqlFileEntry[], path: string): string[] | null {
|
||||
for (const entry of entries) {
|
||||
@@ -159,7 +165,40 @@ async function addFolder(folderPath: string) {
|
||||
};
|
||||
folders.value.push(folder);
|
||||
saveFolders();
|
||||
await loadFolderEntries(folderPath);
|
||||
return loadFolderEntries(folderPath);
|
||||
}
|
||||
|
||||
async function locateCurrentFile() {
|
||||
const path = currentFilePath.value;
|
||||
if (!path || locatingFile.value) return;
|
||||
if (activeFileTarget.value) {
|
||||
await locateActiveFile();
|
||||
return;
|
||||
}
|
||||
if (!isTauriRuntime()) {
|
||||
toast(t("sqlFileTree.desktopOnly"), 3000);
|
||||
return;
|
||||
}
|
||||
locatingFile.value = true;
|
||||
try {
|
||||
const normalizedPath = normalizeExternalSqlPath(path);
|
||||
const folder = folders.value.find((folder) => normalizedPath.startsWith(`${normalizeExternalSqlPath(folder.path).replace(/\/$/, "")}/`));
|
||||
if (folder) {
|
||||
if (await loadFolderEntries(folder.path)) return;
|
||||
} else {
|
||||
const slashedPath = path.replace(/\\/g, "/");
|
||||
const parentPath = slashedPath.slice(0, slashedPath.lastIndexOf("/") + 1);
|
||||
if (!parentPath) return;
|
||||
if (await addFolder(parentPath)) return;
|
||||
}
|
||||
if (!activeFileTarget.value) {
|
||||
toast(t("sqlFileTree.activeFileNotFound"), 5000);
|
||||
return;
|
||||
}
|
||||
await locateActiveFile();
|
||||
} finally {
|
||||
locatingFile.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-scan a single top-level folder and replace its entries. Mutated via the
|
||||
@@ -742,10 +781,8 @@ function clearContextTarget() {
|
||||
{{ t("sqlFileTree.storageHelp") }}
|
||||
</HelpTooltip>
|
||||
<span class="flex-1" />
|
||||
<LightTooltip :text="t('sidebar.locateActiveTab')" side="bottom" :delay="0" :close-delay="0" nowrap>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" :aria-label="t('sidebar.locateActiveTab')" :disabled="!activeFileTarget" @click="locateActiveFile">
|
||||
<LocateFixed class="h-3 w-3" />
|
||||
</Button>
|
||||
<LightTooltip :text="t('sqlFileTree.locateActiveFile')" side="bottom" :delay="0" :close-delay="0" nowrap>
|
||||
<SidebarLocateButton class="disabled:pointer-events-none disabled:opacity-50" :label="t('sqlFileTree.locateActiveFile')" :disabled="!currentFilePath || locatingFile || folders.some((folder) => folder.loading)" @locate="locateCurrentFile" />
|
||||
</LightTooltip>
|
||||
<LightTooltip :text="t('sqlFileTree.filterSettings')" side="bottom" :delay="0" :close-delay="0" nowrap>
|
||||
<Button
|
||||
@@ -856,6 +893,7 @@ function clearContextTarget() {
|
||||
v-for="{ entry, depth } in flatTree(folder.entries, folder.expanded)"
|
||||
:key="entry.path"
|
||||
:data-sql-file-path="entry.path"
|
||||
:aria-current="activePath === entry.path ? 'true' : undefined"
|
||||
class="flex cursor-default select-none items-center gap-1 px-2 py-1 text-sm"
|
||||
:class="[entry.is_dir ? 'rounded-sm' : 'rounded-none', isPathHighlighted(entry.path) ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/40']"
|
||||
:style="{ paddingLeft: depth * 16 + 8 + 'px' }"
|
||||
|
||||
@@ -40,7 +40,7 @@ async function mountPanel(path?: string) {
|
||||
app.mount(host);
|
||||
mounted.push({ app, host });
|
||||
await vi.waitFor(() => expect(host.textContent).toContain("other.sql"));
|
||||
const button = host.querySelector<HTMLButtonElement>(`button[aria-label="${i18n.global.t("sidebar.locateActiveTab")}"]`);
|
||||
const button = host.querySelector<HTMLButtonElement>(`button[aria-label="${i18n.global.t("sqlFileTree.locateActiveFile")}"]`);
|
||||
expect(button).not.toBeNull();
|
||||
return { host, button: button!, queryStore };
|
||||
}
|
||||
@@ -94,9 +94,9 @@ describe("SQL file panel locate active file", () => {
|
||||
expect(host.querySelector('[data-sql-file-path="/sql/nested/deep/current.sql"]')!.classList.contains("bg-accent")).toBe(false);
|
||||
});
|
||||
|
||||
it.each([undefined, "/outside/current.sql"])("disables locating when the active file is unavailable: %s", async (path) => {
|
||||
it.each([undefined, "/outside/current.sql"])("does not locate an unavailable file in the Web runtime: %s", async (path) => {
|
||||
const { button } = await mountPanel(path);
|
||||
expect(button.disabled).toBe(true);
|
||||
expect(button.disabled).toBe(path === undefined);
|
||||
button.click();
|
||||
await nextTick();
|
||||
expect(scrollIntoView).not.toHaveBeenCalled();
|
||||
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SqlFileEntry } from "@/lib/backend/api";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
query: { tabs: [] as Array<{ id: string; mode: string; externalSqlPath?: string }>, activeTabId: "" },
|
||||
list: vi.fn(),
|
||||
read: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
desktop: true,
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => mocks.query }));
|
||||
vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => ({}) }));
|
||||
vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => ({ editorSettings: {} }) }));
|
||||
vi.mock("@/lib/backend/api", () => ({ listSqlFilesInFolder: mocks.list, readExternalSqlFileSnapshot: mocks.read }));
|
||||
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => mocks.desktop }));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
|
||||
|
||||
import SqlFilePanel from "../SqlFilePanel.vue";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { DEFAULT_SQL_FILE_FILTER, getSqlFileFolderPaths, saveSqlFileFilter, saveSqlFileFolderPaths } from "@/lib/sqlFile/sqlFileFolders";
|
||||
|
||||
function entry(path: string, children?: SqlFileEntry[]): SqlFileEntry {
|
||||
return { path, name: path.split(/[\\/]/).pop()!, is_dir: children !== undefined, children: children ?? [] };
|
||||
}
|
||||
|
||||
const filePath = "C:/repo/sql/reports/current.sql";
|
||||
const tree = [entry("C:/repo/sql", [entry("C:/repo/sql/reports", [entry(filePath)])])];
|
||||
const apps: App[] = [];
|
||||
const scroll = vi.fn();
|
||||
const originalScroll = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollIntoView");
|
||||
|
||||
async function mount(paths = ["C:/repo"]) {
|
||||
saveSqlFileFolderPaths(paths);
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const app = createApp(defineComponent({ setup: () => () => h(TooltipProvider, null, { default: () => h(SqlFilePanel) }) }));
|
||||
app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false }));
|
||||
app.mount(host);
|
||||
apps.push(app);
|
||||
await vi.waitFor(() => expect(locateButton(host).disabled).toBe(!mocks.query.tabs.find((tab) => tab.id === mocks.query.activeTabId)?.externalSqlPath));
|
||||
return host;
|
||||
}
|
||||
|
||||
function locateButton(host: HTMLElement) {
|
||||
const button = host.querySelector<HTMLButtonElement>("[data-sidebar-locate-button]");
|
||||
if (!button) throw new Error("Locate button was not rendered");
|
||||
return button;
|
||||
}
|
||||
|
||||
function fileRow(host: HTMLElement, path = filePath) {
|
||||
return Array.from(host.querySelectorAll<HTMLElement>("[data-sql-file-path]")).find((row) => row.dataset.sqlFilePath === path);
|
||||
}
|
||||
|
||||
async function locate(host: HTMLElement) {
|
||||
locateButton(host).click();
|
||||
await nextTick();
|
||||
await vi.waitFor(() => expect(locateButton(host).disabled).toBe(false));
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
describe("SQL file panel current file location", () => {
|
||||
beforeEach(() => {
|
||||
const storage = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
removeItem: (key: string) => storage.delete(key),
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
mocks.desktop = true;
|
||||
mocks.query = reactive({ tabs: [{ id: "current", mode: "query", externalSqlPath: filePath }], activeTabId: "current" });
|
||||
mocks.list.mockResolvedValue(tree);
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { configurable: true, value: scroll });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of apps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
if (originalScroll) Object.defineProperty(HTMLElement.prototype, "scrollIntoView", originalScroll);
|
||||
else Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("expands a collapsed root and all parents, selects and scrolls without reopening the file", async () => {
|
||||
const host = await mount();
|
||||
const root = host.querySelector<HTMLElement>("[data-sql-file-row]")!;
|
||||
root.click();
|
||||
await nextTick();
|
||||
expect(fileRow(host)).toBeUndefined();
|
||||
await locate(host);
|
||||
|
||||
const row = fileRow(host)!;
|
||||
expect(row.getAttribute("aria-current")).toBe("true");
|
||||
expect(row.closest<HTMLElement>("[style*='display: none']")).toBeNull();
|
||||
expect(scroll).toHaveBeenCalledWith({ block: "center", inline: "nearest" });
|
||||
expect(scroll.mock.contexts.at(-1)).toBe(row);
|
||||
expect(mocks.read).not.toHaveBeenCalled();
|
||||
expect(mocks.list).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses the currently focused tab and replaces the previous selection", async () => {
|
||||
const otherPath = "C:/repo/other.sql";
|
||||
mocks.list.mockResolvedValue([...tree, entry(otherPath)]);
|
||||
const host = await mount();
|
||||
await locate(host);
|
||||
mocks.query.tabs.push({ id: "other", mode: "query", externalSqlPath: otherPath });
|
||||
mocks.query.activeTabId = "other";
|
||||
await nextTick();
|
||||
await locate(host);
|
||||
|
||||
expect(fileRow(host)?.hasAttribute("aria-current")).toBe(false);
|
||||
expect(fileRow(host, otherPath)?.getAttribute("aria-current")).toBe("true");
|
||||
expect(mocks.read).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["C:/outside/current.sql", "C:\\outside\\current.sql"])("matches Windows scan path %s and locates on the first click", async (scanPath) => {
|
||||
mocks.query.tabs[0].externalSqlPath = "C:\\outside\\current.sql";
|
||||
mocks.list.mockResolvedValue([entry(scanPath)]);
|
||||
const host = await mount([]);
|
||||
await locate(host);
|
||||
|
||||
expect(mocks.list).toHaveBeenCalledWith("C:/outside/", DEFAULT_SQL_FILE_FILTER, true);
|
||||
expect(getSqlFileFolderPaths()).toEqual(["C:/outside/"]);
|
||||
const row = fileRow(host, scanPath)!;
|
||||
expect(row.getAttribute("aria-current")).toBe("true");
|
||||
expect(scroll.mock.contexts.at(-1)).toBe(row);
|
||||
});
|
||||
|
||||
it("refreshes a known folder to locate a newly added file without adding another root", async () => {
|
||||
mocks.list.mockResolvedValueOnce([]).mockResolvedValueOnce(tree);
|
||||
const host = await mount();
|
||||
await locate(host);
|
||||
|
||||
expect(mocks.list).toHaveBeenCalledTimes(2);
|
||||
expect(getSqlFileFolderPaths()).toEqual(["C:/repo"]);
|
||||
expect(fileRow(host)?.getAttribute("aria-current")).toBe("true");
|
||||
expect(scroll.mock.contexts.at(-1)).toBe(fileRow(host));
|
||||
});
|
||||
|
||||
it("keeps the file filter and reports hidden or missing files", async () => {
|
||||
saveSqlFileFilter("*.py");
|
||||
mocks.list.mockResolvedValue([]);
|
||||
const host = await mount();
|
||||
await locate(host);
|
||||
|
||||
expect(mocks.list).toHaveBeenLastCalledWith("C:/repo", "*.py", true);
|
||||
expect(mocks.toast).toHaveBeenCalledWith("sqlFileTree.activeFileNotFound", 5000);
|
||||
expect(scroll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports refresh errors and releases the locate button", async () => {
|
||||
mocks.list.mockResolvedValueOnce([]).mockRejectedValueOnce(new Error("Folder unavailable"));
|
||||
const host = await mount();
|
||||
await locate(host);
|
||||
|
||||
expect(mocks.toast).toHaveBeenCalledWith("sqlFileTree.loadFailed", 5000);
|
||||
expect(mocks.toast).not.toHaveBeenCalledWith("sqlFileTree.activeFileNotFound", 5000);
|
||||
expect(scroll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables location for unsaved queries and enables it when a file tab becomes active", async () => {
|
||||
mocks.query.tabs.push({ id: "unsaved", mode: "query" });
|
||||
mocks.query.activeTabId = "unsaved";
|
||||
const host = await mount([]);
|
||||
expect(locateButton(host).disabled).toBe(true);
|
||||
mocks.query.activeTabId = "current";
|
||||
await nextTick();
|
||||
expect(locateButton(host).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("does not list local files from the Web runtime", async () => {
|
||||
mocks.desktop = false;
|
||||
const host = await mount([]);
|
||||
await locate(host);
|
||||
expect(mocks.list).not.toHaveBeenCalled();
|
||||
expect(mocks.toast).toHaveBeenCalledWith("sqlFileTree.desktopOnly", 3000);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, nextTick } from "vue";
|
||||
import { computed, defineAsyncComponent, ref, watch, nextTick } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Command, FileCode, FileText, Search, FolderPlus, SlidersHorizontal, X } from "@lucide/vue";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
@@ -12,6 +12,8 @@ import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { formatShortcutDisplay } from "@/lib/editor/shortcutDisplay";
|
||||
import { getGlobalSearchRoots, saveGlobalSearchRoots, getGlobalSearchExtensions, saveGlobalSearchExtensions } from "@/lib/globalSearch/globalSearchSettings";
|
||||
|
||||
const QuickOpenFilePreview = defineAsyncComponent(() => import("./QuickOpenFilePreview.vue"));
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
/** When true, start in content (full-text) search mode on open. */
|
||||
@@ -28,8 +30,42 @@ const { t } = useI18n();
|
||||
// list refresh is driven by dbx:plugins-changed / focus / locale change, with a fallback refresh when the dialog opens.
|
||||
const pluginPalette = usePluginCommandPalette();
|
||||
const { toast } = useToast();
|
||||
const { searchQuery, filteredItems, selectedIndex, selectedItem, selectNext, selectPrevious, setQuery, loadExternalSqlFiles, loadPluginWorkbenches, contentMode, contentGroups, contentSelectedItem, contentSearching, setContentMode } = useQuickOpen({ extraItems: pluginPalette.items });
|
||||
const inputRef = ref<HTMLInputElement | null>(null);
|
||||
const {
|
||||
searchQuery,
|
||||
filteredItems,
|
||||
selectedIndex,
|
||||
selectedItem,
|
||||
selectNext,
|
||||
selectPrevious,
|
||||
setQuery,
|
||||
loadExternalSqlFiles,
|
||||
loadPluginWorkbenches,
|
||||
contentMode,
|
||||
contentGroups,
|
||||
contentSelectedItem,
|
||||
contentSearching,
|
||||
setContentMode: setSearchContentMode,
|
||||
} = useQuickOpen({ extraItems: pluginPalette.items });
|
||||
const previewEditing = ref(false);
|
||||
const pinnedPreviewItem = ref<QuickOpenItem | null>(null);
|
||||
const previewItem = computed(() => (previewEditing.value ? pinnedPreviewItem.value : contentSelectedItem.value));
|
||||
|
||||
function handlePreviewEditing(value: boolean): void {
|
||||
if (value) pinnedPreviewItem.value = contentSelectedItem.value ? { ...contentSelectedItem.value } : null;
|
||||
if (value) searchSettingsOpen.value = false;
|
||||
previewEditing.value = value;
|
||||
}
|
||||
|
||||
function canLeavePreview(): boolean {
|
||||
if (!previewEditing.value) return true;
|
||||
toast(t("quickOpen.finishEditing"));
|
||||
return false;
|
||||
}
|
||||
|
||||
function setContentMode(value: boolean): void {
|
||||
if (canLeavePreview()) setSearchContentMode(value);
|
||||
}
|
||||
const inputRef = ref<InstanceType<typeof Input> | null>(null);
|
||||
const listRef = ref<HTMLElement | null>(null);
|
||||
const settingsStore = useSettingsStore();
|
||||
const globalSearchShortcut = computed(() => formatShortcutDisplay(settingsStore.editorSettings.shortcuts.globalSearch || "Mod+Shift+F"));
|
||||
@@ -112,10 +148,13 @@ function getHighlightedLineParts(item: QuickOpenItem): Array<{ text: string; hig
|
||||
|
||||
const dialogOpen = computed({
|
||||
get: () => props.open,
|
||||
set: (value) => emit("update:open", value),
|
||||
set: (value) => {
|
||||
if (value || canLeavePreview()) emit("update:open", value);
|
||||
},
|
||||
});
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent): void {
|
||||
if (previewEditing.value) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
selectNext();
|
||||
@@ -134,7 +173,19 @@ function handleKeyDown(e: KeyboardEvent): void {
|
||||
}
|
||||
}
|
||||
|
||||
function handlePreviewNavigate(direction: -1 | 1): void {
|
||||
if (previewEditing.value) return;
|
||||
if (direction === 1) selectNext();
|
||||
else selectPrevious();
|
||||
inputRef.value?.$el.focus();
|
||||
}
|
||||
|
||||
function openPreviewMatch(): void {
|
||||
if (contentSelectedItem.value) handleSelect(contentSelectedItem.value);
|
||||
}
|
||||
|
||||
function handleSelect(item: QuickOpenItem): void {
|
||||
if (!canLeavePreview()) return;
|
||||
// Plugin commands execute inside this component: they are NOT dispatched to App.vue object navigation (that chain
|
||||
// connectionId-based connection/expand logic is meaningless for command entries); execution errors surface via the existing toast.
|
||||
if (item.type === "plugin_command") {
|
||||
@@ -247,7 +298,7 @@ watch(
|
||||
// Refresh plugin workbench entries so installs/uninstalls show up without a restart
|
||||
void loadPluginWorkbenches();
|
||||
nextTick(() => {
|
||||
inputRef.value?.focus();
|
||||
inputRef.value?.$el.focus();
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -274,13 +325,14 @@ watch(selectedIndex, async () => {
|
||||
<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">
|
||||
<div class="flex max-h-[calc(var(--dbx-viewport-height)-2rem)] min-h-0 flex-col bg-background">
|
||||
<!-- Search Input -->
|
||||
<div class="flex items-center gap-3 px-4 pr-12 py-3 border-b">
|
||||
<div class="flex shrink-0 items-center gap-3 px-4 pr-12 py-3 border-b">
|
||||
<Command class="h-5 w-5 text-muted-foreground" />
|
||||
<Input
|
||||
ref="inputRef"
|
||||
v-model="searchQuery"
|
||||
:disabled="previewEditing"
|
||||
type="text"
|
||||
:placeholder="contentMode ? t('quickOpen.contentPlaceholder') : t('quickOpen.placeholder')"
|
||||
class="flex-1 border-0 bg-transparent p-0 placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:outline-none"
|
||||
@@ -289,22 +341,28 @@ watch(selectedIndex, async () => {
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<!-- Object/file ⇄ file content mode toggle -->
|
||||
<div class="flex items-center rounded-md bg-muted p-0.5 text-muted-foreground">
|
||||
<button class="flex h-7 w-7 items-center justify-center rounded" :class="contentMode ? 'hover:bg-muted-foreground/10' : 'bg-background text-foreground shadow-sm'" :title="t('quickOpen.modeObjects')" @click="setContentMode(false)">
|
||||
<button :disabled="previewEditing" class="flex h-7 w-7 items-center justify-center rounded disabled:opacity-50" :class="contentMode ? 'hover:bg-muted-foreground/10' : 'bg-background text-foreground shadow-sm'" :title="t('quickOpen.modeObjects')" @click="setContentMode(false)">
|
||||
<FileCode class="h-4 w-4" />
|
||||
</button>
|
||||
<button class="flex h-7 w-7 items-center justify-center rounded" :class="contentMode ? 'bg-background text-foreground shadow-sm' : 'hover:bg-muted-foreground/10'" :title="t('quickOpen.modeContent')" @click="setContentMode(true)">
|
||||
<button :disabled="previewEditing" class="flex h-7 w-7 items-center justify-center rounded disabled:opacity-50" :class="contentMode ? 'bg-background text-foreground shadow-sm' : 'hover:bg-muted-foreground/10'" :title="t('quickOpen.modeContent')" @click="setContentMode(true)">
|
||||
<Search class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Search settings (roots & extensions) -->
|
||||
<button class="flex h-7 w-7 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground" :class="searchSettingsOpen ? 'bg-muted text-foreground' : ''" :title="t('quickOpen.searchSettings')" @click="searchSettingsOpen = !searchSettingsOpen">
|
||||
<button
|
||||
:disabled="previewEditing"
|
||||
class="flex h-7 w-7 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-50"
|
||||
:class="searchSettingsOpen ? 'bg-muted text-foreground' : ''"
|
||||
:title="t('quickOpen.searchSettings')"
|
||||
@click="searchSettingsOpen = !searchSettingsOpen"
|
||||
>
|
||||
<SlidersHorizontal class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search settings panel -->
|
||||
<div v-if="searchSettingsOpen" class="space-y-3 border-b bg-muted/30 px-4 py-3 text-sm">
|
||||
<div v-if="searchSettingsOpen" class="max-h-[30vh] shrink-0 space-y-3 overflow-y-auto border-b bg-muted/30 px-4 py-3 text-sm">
|
||||
<div>
|
||||
<div class="mb-1.5 flex items-center justify-between">
|
||||
<span class="text-xs text-muted-foreground">{{ t("quickOpen.searchRoots") }}</span>
|
||||
@@ -332,7 +390,7 @@ watch(selectedIndex, async () => {
|
||||
</div>
|
||||
|
||||
<!-- Results List -->
|
||||
<div ref="listRef" class="max-h-[400px] overflow-y-auto">
|
||||
<div ref="listRef" class="min-h-0 overflow-y-auto" :class="contentMode ? 'max-h-[min(36vh,320px)]' : 'max-h-[400px]'">
|
||||
<!-- Content search mode: group matches by file (DataGrip-style) -->
|
||||
<template v-if="contentMode">
|
||||
<div v-if="contentSearching" class="px-4 py-8 text-center text-muted-foreground">
|
||||
@@ -353,7 +411,7 @@ watch(selectedIndex, async () => {
|
||||
<span class="text-xs text-muted-foreground whitespace-nowrap">{{ row.count }}</span>
|
||||
</div>
|
||||
<!-- Match line -->
|
||||
<div v-else class="px-4 py-1.5 cursor-pointer" :data-selected="row.flatIndex === selectedIndex" :class="[row.flatIndex === selectedIndex ? 'bg-accent' : 'hover:bg-muted']" @click="handleSelect(row.item!)" @mouseenter="selectedIndex = row.flatIndex">
|
||||
<div v-else class="px-4 py-1.5 cursor-pointer" :data-selected="row.flatIndex === selectedIndex" :class="[row.flatIndex === selectedIndex ? 'bg-accent' : 'hover:bg-muted']" @click="handleSelect(row.item!)" @mouseenter="!previewEditing && (selectedIndex = row.flatIndex)">
|
||||
<!-- Filename-only match (no content hit): show highlighted file name -->
|
||||
<div v-if="row.item!.line === 0" class="flex items-center gap-2 min-w-0">
|
||||
<FileText class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
@@ -413,10 +471,13 @@ watch(selectedIndex, async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<QuickOpenFilePreview v-if="previewItem && (previewEditing || (contentMode && !contentSearching))" :item="previewItem" class="h-[min(32vh,280px)] shrink-0" @editing="handlePreviewEditing" @navigate="handlePreviewNavigate" @open="openPreviewMatch" @close="dialogOpen = false" />
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="px-4 py-2 border-t text-xs text-muted-foreground flex justify-between">
|
||||
<div class="shrink-0 flex flex-wrap justify-between gap-2 px-4 py-2 border-t text-xs text-muted-foreground">
|
||||
<div>{{ contentMode ? contentMatchCount : filteredItems.length }} {{ t("quickOpen.results") }}</div>
|
||||
<div class="flex gap-4">
|
||||
<div v-if="previewEditing" role="status">{{ t("quickOpen.finishEditing") }}</div>
|
||||
<div v-else 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>
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Compartment, EditorState, type Text } from "@codemirror/state";
|
||||
import { Decoration, drawSelection, EditorView, keymap, lineNumbers } from "@codemirror/view";
|
||||
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
|
||||
import { sql } from "@codemirror/lang-sql";
|
||||
import { Copy, Pencil, Save } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { editorClipboardLineEndingsExtension } from "@/lib/editor/editorClipboardLineEndings";
|
||||
import { editorFontTheme, loadEditorTheme } from "@/lib/editor/editorThemes";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import type { QuickOpenItem } from "@/composables/useQuickOpen";
|
||||
|
||||
const props = defineProps<{ item: QuickOpenItem }>();
|
||||
const emit = defineEmits<{ navigate: [direction: -1 | 1]; open: []; close: []; editing: [value: boolean] }>();
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { isDark, themePalette } = useTheme();
|
||||
const editorHost = ref<HTMLElement | null>(null);
|
||||
const content = ref<string | null>(null);
|
||||
const loading = ref(true);
|
||||
const failed = ref(false);
|
||||
const editing = ref(false);
|
||||
const saving = ref(false);
|
||||
const dirty = ref(false);
|
||||
const saveError = ref("");
|
||||
let snapshot: Awaited<ReturnType<typeof api.readExternalSqlFileSnapshot>> | null = null;
|
||||
let savedDocument: Text | null = null;
|
||||
const fileName = computed(() => props.item.fileName || props.item.label);
|
||||
const theme = new Compartment();
|
||||
const font = new Compartment();
|
||||
const match = new Compartment();
|
||||
const editability = new Compartment();
|
||||
const undoHistory = new Compartment();
|
||||
let view: EditorView | null = null;
|
||||
|
||||
// Match the global-search file-size bound. The backend checks the limit before reading.
|
||||
const MAX_PREVIEW_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
async function copyPreview(): Promise<void> {
|
||||
if (loading.value || failed.value || content.value === null) return;
|
||||
const selection = view?.state.selection;
|
||||
const text = view && selection && !selection.main.empty ? view.state.sliceDoc(selection.main.from, selection.main.to) : view ? view.state.sliceDoc() : content.value;
|
||||
try {
|
||||
await copyToClipboard(text);
|
||||
toast(t("grid.copied"));
|
||||
} catch (error) {
|
||||
toast(t("grid.copyFailed", { message: error instanceof Error ? error.message : String(error) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
async function startEditing(): Promise<void> {
|
||||
if (loading.value || failed.value || !snapshot || editing.value) return;
|
||||
saveError.value = "";
|
||||
editing.value = true;
|
||||
emit("editing", true);
|
||||
// An empty preview creates its editor when editing starts.
|
||||
await nextTick();
|
||||
if (!view) return;
|
||||
savedDocument = view.state.doc;
|
||||
view.dispatch({ effects: [editability.reconfigure([]), undoHistory.reconfigure(history()), match.reconfigure([])] });
|
||||
view.focus();
|
||||
}
|
||||
|
||||
function finishEditing(): void {
|
||||
editing.value = false;
|
||||
dirty.value = false;
|
||||
saveError.value = "";
|
||||
view?.dispatch({ effects: [editability.reconfigure([EditorState.readOnly.of(true), EditorView.editable.of(false)]), undoHistory.reconfigure([])] });
|
||||
emit("editing", false);
|
||||
}
|
||||
|
||||
function cancelEditing(): void {
|
||||
if (saving.value) return;
|
||||
if (view && savedDocument) view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: savedDocument } });
|
||||
finishEditing();
|
||||
revealMatch();
|
||||
}
|
||||
|
||||
async function savePreview(): Promise<void> {
|
||||
if (!editing.value || saving.value || !view || !snapshot || !props.item.filePath) return;
|
||||
if (!dirty.value) {
|
||||
finishEditing();
|
||||
return;
|
||||
}
|
||||
const lineEnding = snapshot.content.includes("\r\n") ? "\r\n" : snapshot.content.includes("\r") ? "\r" : "\n";
|
||||
const text = view.state.doc.toString().replace(/\n/g, lineEnding);
|
||||
saving.value = true;
|
||||
saveError.value = "";
|
||||
view.dispatch({ effects: editability.reconfigure([EditorState.readOnly.of(true), EditorView.editable.of(false)]) });
|
||||
try {
|
||||
const result = await api.writeExternalSqlFile(props.item.filePath, text, { expectedContentHash: snapshot.version.contentHash, encoding: snapshot.encoding === "auto" ? "utf8" : snapshot.encoding });
|
||||
if (result.kind !== "written") {
|
||||
saveError.value = t(result.kind === "conflict" ? "externalSqlFile.modifiedDescription" : "externalSqlFile.deletedDescription", { path: props.item.filePath });
|
||||
return;
|
||||
}
|
||||
snapshot = { ...snapshot, content: text, version: result.version };
|
||||
savedDocument = view.state.doc;
|
||||
content.value = text;
|
||||
// Keep the editor instance and selection after saving.
|
||||
finishEditing();
|
||||
toast(t("savedSql.saved"));
|
||||
} catch (error) {
|
||||
saveError.value = t("savedSql.saveFailed", { message: error instanceof Error ? error.message : String(error) });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
if (editing.value) view?.dispatch({ effects: editability.reconfigure([]) });
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.item.filePath,
|
||||
(path, _previous, onCleanup) => {
|
||||
let cancelled = false;
|
||||
loading.value = true;
|
||||
failed.value = false;
|
||||
content.value = null;
|
||||
snapshot = null;
|
||||
savedDocument = null;
|
||||
dirty.value = false;
|
||||
saveError.value = "";
|
||||
// Briefly debounce navigation across files; line changes within a file never reread it.
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
if (!path) throw new Error("Missing preview path");
|
||||
const result = await api.readExternalSqlFileSnapshot(path, MAX_PREVIEW_BYTES);
|
||||
if (!cancelled) {
|
||||
snapshot = result;
|
||||
content.value = result.content;
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) failed.value = true;
|
||||
} finally {
|
||||
if (!cancelled) loading.value = false;
|
||||
}
|
||||
}, 100);
|
||||
onCleanup(() => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function revealMatch(): void {
|
||||
if (!view) return;
|
||||
const lineNumber = props.item.line ?? 0;
|
||||
if (lineNumber <= 0) {
|
||||
view.dispatch({ selection: { anchor: 0 }, effects: [match.reconfigure([]), EditorView.scrollIntoView(0, { y: "start" })] });
|
||||
return;
|
||||
}
|
||||
const line = view.state.doc.line(Math.min(lineNumber, view.state.doc.lines));
|
||||
// The backend counts Unicode characters; CodeMirror positions use UTF-16.
|
||||
let remainingCharacters = Math.max(0, (props.item.column ?? 1) - 1);
|
||||
let columnOffset = 0;
|
||||
for (const character of line.text) {
|
||||
if (remainingCharacters-- <= 0) break;
|
||||
columnOffset += character.length;
|
||||
}
|
||||
const start = line.from + columnOffset;
|
||||
const end = Math.min(start + (props.item.matchText?.length ?? 0), line.to);
|
||||
const decorations = [Decoration.line({ class: "cm-preview-match-line" }).range(line.from)];
|
||||
if (end > start) decorations.push(Decoration.mark({ class: "cm-preview-match" }).range(start, end));
|
||||
view.dispatch({
|
||||
selection: { anchor: start },
|
||||
effects: [match.reconfigure(EditorView.decorations.of(Decoration.set(decorations, true))), EditorView.scrollIntoView(start, { y: "center", x: "nearest" })],
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
[editorHost, content],
|
||||
([host, text]) => {
|
||||
if (view && host && view.dom.parentNode === host && text === snapshot?.content) return;
|
||||
view?.destroy();
|
||||
view = null;
|
||||
if (!host || text === null) return;
|
||||
view = new EditorView({
|
||||
parent: host,
|
||||
state: EditorState.create({
|
||||
doc: text,
|
||||
extensions: [
|
||||
editability.of([EditorState.readOnly.of(true), EditorView.editable.of(false)]),
|
||||
undoHistory.of([]),
|
||||
EditorView.contentAttributes.of({ tabindex: "0", "aria-label": props.item.filePath || fileName.value }),
|
||||
lineNumbers(),
|
||||
drawSelection(),
|
||||
editorClipboardLineEndingsExtension(EditorView),
|
||||
keymap.of([
|
||||
{
|
||||
key: "Mod-s",
|
||||
run: () => {
|
||||
if (!editing.value) return false;
|
||||
void savePreview();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "Escape",
|
||||
run: () => {
|
||||
if (editing.value) {
|
||||
view?.contentDOM.blur();
|
||||
return true;
|
||||
}
|
||||
emit("close");
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "Enter",
|
||||
run: () => {
|
||||
if (editing.value) return false;
|
||||
emit("open");
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "ArrowUp",
|
||||
run: () => {
|
||||
if (editing.value) return false;
|
||||
emit("navigate", -1);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "ArrowDown",
|
||||
run: () => {
|
||||
if (editing.value) return false;
|
||||
emit("navigate", 1);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
]),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged && editing.value && savedDocument) dirty.value = !update.state.doc.eq(savedDocument);
|
||||
}),
|
||||
/\.sql$/i.test(props.item.filePath || "") ? sql() : [],
|
||||
theme.of([]),
|
||||
font.of(editorFontTheme(EditorView, settingsStore.editorSettings.fontSize, settingsStore.editorSettings.fontFamily, { fixedHeight: true, scrollable: true })),
|
||||
match.of([]),
|
||||
EditorView.theme({
|
||||
"&": { height: "100%", backgroundColor: "var(--background)", color: "var(--foreground)" },
|
||||
".cm-scroller": { overflow: "auto" },
|
||||
".cm-gutters": { backgroundColor: "var(--muted)", color: "var(--muted-foreground)", border: "none" },
|
||||
".cm-selectionLayer .cm-selectionBackground": { display: "block" },
|
||||
".cm-preview-match-line": { backgroundColor: "var(--info-bg)", boxShadow: "inset 3px 0 0 var(--info)" },
|
||||
".cm-preview-match": { backgroundColor: "var(--warning-bg)", color: "var(--foreground)", borderRadius: "2px" },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
revealMatch();
|
||||
void applyTheme();
|
||||
},
|
||||
{ flush: "post" },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [props.item.line, props.item.column, props.item.matchText],
|
||||
() => {
|
||||
if (!editing.value) revealMatch();
|
||||
},
|
||||
{ flush: "post" },
|
||||
);
|
||||
|
||||
let themeRevision = 0;
|
||||
async function applyTheme(): Promise<void> {
|
||||
const revision = ++themeRevision;
|
||||
const target = view;
|
||||
if (!target) return;
|
||||
try {
|
||||
const extension = await loadEditorTheme(settingsStore.editorSettings.theme, isDark.value ? "dark" : "light", settingsStore.editorSettings.customThemeColors, themePalette.value);
|
||||
if (view === target && revision === themeRevision) target.dispatch({ effects: theme.reconfigure(extension) });
|
||||
} catch {
|
||||
// File contents remain readable even if an optional editor theme cannot load.
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => [isDark.value, themePalette.value, settingsStore.editorSettings.theme, settingsStore.editorSettings.customThemeColors], applyTheme, { deep: true });
|
||||
watch(
|
||||
() => [settingsStore.editorSettings.fontSize, settingsStore.editorSettings.fontFamily],
|
||||
() => view?.dispatch({ effects: font.reconfigure(editorFontTheme(EditorView, settingsStore.editorSettings.fontSize, settingsStore.editorSettings.fontFamily, { fixedHeight: true, scrollable: true })) }),
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
view?.destroy();
|
||||
view = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex min-h-0 flex-col border-t" :aria-label="t('quickOpen.filePreview')" :aria-busy="loading">
|
||||
<div class="flex h-8 shrink-0 items-center gap-2 border-b bg-muted/30 px-4 text-xs text-muted-foreground">
|
||||
<span class="shrink-0">{{ t("quickOpen.filePreview") }}</span>
|
||||
<span v-if="dirty" class="shrink-0 text-warning" :title="t('editor.unsavedChangesTitle')" :aria-label="t('editor.unsavedChangesTitle')">●</span>
|
||||
<span class="min-w-0 flex-1 truncate" :title="item.filePath">{{ item.filePath || fileName }}</span>
|
||||
<span v-if="item.line && item.line > 0" class="shrink-0 font-mono">{{ item.line }}:{{ item.column || 1 }}</span>
|
||||
<Button variant="ghost" size="icon-sm" :title="t('common.copy')" :aria-label="t('common.copy')" :disabled="loading || failed || content === null" @click="copyPreview"><Copy class="h-3.5 w-3.5" /></Button>
|
||||
<template v-if="editing">
|
||||
<Button variant="ghost" size="xs" :disabled="saving" @click="savePreview"><Save class="h-3.5 w-3.5" />{{ t("common.save") }}</Button>
|
||||
<Button variant="ghost" size="xs" :disabled="saving" @click="cancelEditing">{{ t("common.cancel") }}</Button>
|
||||
</template>
|
||||
<Button v-else variant="ghost" size="icon-sm" :title="t('common.edit')" :aria-label="t('common.edit')" :disabled="loading || failed || content === null" @click="startEditing"><Pencil class="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
<div v-if="saveError" role="alert" class="shrink-0 border-b px-4 py-2 text-xs text-destructive">{{ saveError }}</div>
|
||||
<div class="relative min-h-0 flex-1">
|
||||
<div v-if="loading" role="status" class="flex h-full items-center justify-center text-xs text-muted-foreground">{{ t("common.loading") }}</div>
|
||||
<div v-else-if="failed" role="status" class="flex h-full items-center justify-center text-xs text-muted-foreground">{{ t("quickOpen.previewFailed") }}</div>
|
||||
<div v-else-if="content === '' && !editing" role="status" class="flex h-full items-center justify-center text-xs text-muted-foreground">{{ t("quickOpen.previewEmpty") }}</div>
|
||||
<div v-else ref="editorHost" data-native-clipboard class="h-full overflow-hidden" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, h, nextTick, reactive, type App } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ select: vi.fn(), updateOpen: vi.fn(), read: vi.fn(), write: vi.fn(), toast: vi.fn() }));
|
||||
vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => ({ connections: [], treeNodes: [], connectedIds: new Set() }) }));
|
||||
vi.mock("@/stores/savedSqlStore", () => ({ useSavedSqlStore: () => ({ allFiles: [] }) }));
|
||||
vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => ({ editorSettings: { shortcuts: {}, fontSize: 13, fontFamily: "monospace", theme: "app" } }) }));
|
||||
vi.mock("@/composables/useTheme", () => ({ useTheme: () => ({ isDark: { value: false }, themePalette: { value: "pearl" } }) }));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
|
||||
vi.mock("@/lib/editor/editorThemes", () => ({ editorFontTheme: () => [], loadEditorTheme: async () => [] }));
|
||||
vi.mock("@/lib/backend/safeStorage", () => ({ safeLocalStorageGet: () => null, safeLocalStorageSet: vi.fn() }));
|
||||
vi.mock("@/components/plugins/PluginIcon.vue", () => ({ default: { render: () => null } }));
|
||||
vi.mock("@/lib/plugins/pluginCommandPalette", async () => {
|
||||
const { ref } = await import("vue");
|
||||
return { usePluginCommandPalette: () => ({ items: ref([]), refresh: vi.fn(), open: vi.fn() }) };
|
||||
});
|
||||
vi.mock("@/lib/sqlFile/sqlFileFolders", async () => {
|
||||
const { ref } = await import("vue");
|
||||
return { getSqlFileFolderPaths: () => [], getSqlFileFilter: () => "*.sql", sqlFileFoldersVersion: ref(0) };
|
||||
});
|
||||
vi.mock("@/lib/globalSearch/globalSearchSettings", async () => {
|
||||
const { ref } = await import("vue");
|
||||
return { composeGlobalSearchRoots: () => ["/sql"], getGlobalSearchRoots: () => ["/sql"], getGlobalSearchExtensions: () => ["sql"], globalSearchSettingsVersion: ref(0), saveGlobalSearchRoots: vi.fn(), saveGlobalSearchExtensions: vi.fn() };
|
||||
});
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
listPlugins: async () => [],
|
||||
readExternalSqlFileSnapshot: async (...args: unknown[]) => ({ content: await mocks.read(...args), version: { contentHash: "original", modifiedNs: "1", sizeBytes: 26 }, encoding: "utf8" }),
|
||||
writeExternalSqlFile: mocks.write,
|
||||
globalSearch: async () => [
|
||||
{ path: "/sql/first.sql", fileName: "first.sql", line: 2, column: 8, lineText: "SELECT needle;", matchText: "needle" },
|
||||
{ path: "/sql/second.sql", fileName: "second.sql", line: 1, column: 8, lineText: "SELECT needle, 2;", matchText: "needle" },
|
||||
],
|
||||
}));
|
||||
vi.mock("@/components/ui/dialog", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const passthrough = defineComponent({
|
||||
setup:
|
||||
(_, { slots }) =>
|
||||
() =>
|
||||
h("div", slots.default?.()),
|
||||
});
|
||||
const dialog = defineComponent({
|
||||
emits: ["update:open"],
|
||||
setup:
|
||||
(_, { slots, emit }) =>
|
||||
() =>
|
||||
h("div", [h("button", { "data-close-search": "", onClick: () => emit("update:open", false) }, "Close search"), slots.default?.()]),
|
||||
});
|
||||
return { Dialog: dialog, DialogContent: passthrough };
|
||||
});
|
||||
|
||||
import QuickOpenDialog from "../QuickOpenDialog.vue";
|
||||
import i18n from "@/i18n";
|
||||
|
||||
let app: App;
|
||||
let host: HTMLElement;
|
||||
|
||||
async function mountSearch() {
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const props = reactive({ open: false, initialContentMode: true, onSelect: mocks.select, "onUpdate:open": mocks.updateOpen });
|
||||
app = createApp({ render: () => h(QuickOpenDialog, props) });
|
||||
app.use(i18n);
|
||||
app.mount(host);
|
||||
props.open = true;
|
||||
await nextTick();
|
||||
const input = host.querySelector<HTMLInputElement>("input")!;
|
||||
input.value = "needle";
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await vi.waitFor(() => expect(host.querySelectorAll("[data-selected]")).toHaveLength(2));
|
||||
await vi.waitFor(() => expect(host.querySelector(".cm-editor")).not.toBeNull(), { timeout: 10000 });
|
||||
return { input, props, rows: [...host.querySelectorAll<HTMLElement>("[data-selected]")] };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.select.mockReset();
|
||||
mocks.updateOpen.mockReset();
|
||||
mocks.read.mockReset().mockResolvedValue("-- heading\nSELECT needle;");
|
||||
mocks.write.mockReset().mockResolvedValue({ kind: "written", version: { contentHash: "saved", modifiedNs: "2", sizeBytes: 30 } });
|
||||
mocks.toast.mockReset();
|
||||
vi.spyOn(HTMLElement.prototype, "scrollIntoView").mockImplementation(() => {});
|
||||
});
|
||||
afterEach(() => {
|
||||
app?.unmount();
|
||||
host?.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("content search preview navigation", () => {
|
||||
it("pins the preview while editing and resumes navigation after saving", async () => {
|
||||
const { input, rows, props } = await mountSearch();
|
||||
host.querySelector<HTMLButtonElement>('button[aria-label="Edit"]')!.click();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
const view = EditorView.findFromDOM(host.querySelector<HTMLElement>(".cm-editor")!)!;
|
||||
view.dispatch({ changes: { from: view.state.doc.length, insert: "\nSELECT 3;" } });
|
||||
rows[1]!.dispatchEvent(new MouseEvent("mouseenter"));
|
||||
rows[1]!.click();
|
||||
await nextTick();
|
||||
expect(input.disabled).toBe(true);
|
||||
expect(rows[0]!.dataset.selected).toBe("true");
|
||||
expect(mocks.read).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.select).not.toHaveBeenCalled();
|
||||
expect(mocks.updateOpen).not.toHaveBeenCalled();
|
||||
host.querySelector<HTMLButtonElement>("[data-close-search]")!.click();
|
||||
expect(mocks.updateOpen).not.toHaveBeenCalled();
|
||||
props.initialContentMode = false;
|
||||
await nextTick();
|
||||
expect(host.querySelector(".cm-editor")).not.toBeNull();
|
||||
const save = [...host.querySelectorAll<HTMLButtonElement>("button")].find((node) => node.textContent?.trim() === "Save")!;
|
||||
save.click();
|
||||
await vi.waitFor(() => expect(input.disabled).toBe(false));
|
||||
expect(mocks.write).toHaveBeenCalledWith("/sql/first.sql", "-- heading\nSELECT needle;\nSELECT 3;", expect.objectContaining({ expectedContentHash: "original" }));
|
||||
rows[1]!.dispatchEvent(new MouseEvent("mouseenter"));
|
||||
await vi.waitFor(() => expect(mocks.read).toHaveBeenLastCalledWith("/sql/second.sql", 32 * 1024 * 1024));
|
||||
});
|
||||
|
||||
it("keeps edits after a save error and allows closing after cancellation", async () => {
|
||||
mocks.write.mockRejectedValue(new Error("write failed"));
|
||||
await mountSearch();
|
||||
host.querySelector<HTMLButtonElement>('button[aria-label="Edit"]')!.click();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
const view = EditorView.findFromDOM(host.querySelector<HTMLElement>(".cm-editor")!)!;
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: "my draft" } });
|
||||
[...host.querySelectorAll<HTMLButtonElement>("button")].find((node) => node.textContent?.trim() === "Save")!.click();
|
||||
await vi.waitFor(() => expect(host.querySelector('[role="alert"]')).not.toBeNull());
|
||||
host.querySelector<HTMLButtonElement>("[data-close-search]")!.click();
|
||||
expect(mocks.updateOpen).not.toHaveBeenCalled();
|
||||
expect(view.state.doc.toString()).toBe("my draft");
|
||||
[...host.querySelectorAll<HTMLButtonElement>("button")].find((node) => node.textContent?.trim() === "Cancel")!.click();
|
||||
await nextTick();
|
||||
expect(view.state.doc.toString()).toBe("-- heading\nSELECT needle;");
|
||||
host.querySelector<HTMLButtonElement>("[data-close-search]")!.click();
|
||||
expect(mocks.updateOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("previews hovered results while keeping the dialog open", async () => {
|
||||
const { rows } = await mountSearch();
|
||||
rows[1]!.dispatchEvent(new MouseEvent("mouseenter"));
|
||||
await vi.waitFor(() => expect(mocks.read).toHaveBeenLastCalledWith("/sql/second.sql", 32 * 1024 * 1024));
|
||||
expect(mocks.select).not.toHaveBeenCalled();
|
||||
expect(mocks.updateOpen).not.toHaveBeenCalled();
|
||||
rows[0]!.dispatchEvent(new MouseEvent("mouseenter"));
|
||||
await vi.waitFor(() => expect(mocks.read).toHaveBeenLastCalledWith("/sql/first.sql", 32 * 1024 * 1024));
|
||||
expect(mocks.select).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moves the preview with arrow keys and opens the selected file with Enter", async () => {
|
||||
const { input, rows } = await mountSearch();
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
|
||||
await nextTick();
|
||||
expect(rows[1]!.dataset.selected).toBe("true");
|
||||
await vi.waitFor(() => expect(mocks.read).toHaveBeenLastCalledWith("/sql/second.sql", 32 * 1024 * 1024));
|
||||
expect(document.activeElement).toBe(input);
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
expect(mocks.select).toHaveBeenCalledWith(expect.objectContaining({ filePath: "/sql/second.sql", line: 1 }));
|
||||
expect(mocks.updateOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("hides preview when changing modes", async () => {
|
||||
const { props } = await mountSearch();
|
||||
props.initialContentMode = false;
|
||||
await nextTick();
|
||||
expect(host.querySelector(".cm-editor")).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves single-click opening a search result", async () => {
|
||||
const { rows } = await mountSearch();
|
||||
rows[1]!.click();
|
||||
expect(mocks.select).toHaveBeenCalledOnce();
|
||||
expect(mocks.select).toHaveBeenCalledWith(expect.objectContaining({ filePath: "/sql/second.sql" }));
|
||||
expect(mocks.updateOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("navigates from preview focus, returns focus to search, and opens with Enter", async () => {
|
||||
const { input, rows } = await mountSearch();
|
||||
const preview = host.querySelector<HTMLElement>(".cm-content")!;
|
||||
preview.focus();
|
||||
preview.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true }));
|
||||
await nextTick();
|
||||
expect(rows[1]!.dataset.selected).toBe("true");
|
||||
expect(document.activeElement).toBe(input);
|
||||
await vi.waitFor(() => expect(mocks.read).toHaveBeenLastCalledWith("/sql/second.sql", 32 * 1024 * 1024));
|
||||
await vi.waitFor(() => expect(host.querySelector(".cm-content")).not.toBeNull());
|
||||
const nextPreview = host.querySelector<HTMLElement>(".cm-content")!;
|
||||
nextPreview.focus();
|
||||
nextPreview.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
expect(mocks.select).toHaveBeenCalledWith(expect.objectContaining({ filePath: "/sql/second.sql" }));
|
||||
expect(mocks.updateOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("closes with Escape while the preview has focus", async () => {
|
||||
await mountSearch();
|
||||
const preview = host.querySelector<HTMLElement>(".cm-content")!;
|
||||
preview.focus();
|
||||
preview.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }));
|
||||
expect(mocks.select).not.toHaveBeenCalled();
|
||||
expect(mocks.updateOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
+351
@@ -0,0 +1,351 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, h, nextTick, reactive, type App } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { undo, redo } from "@codemirror/commands";
|
||||
import type { QuickOpenItem } from "@/composables/useQuickOpen";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ read: vi.fn(), write: vi.fn(), copy: vi.fn(), toast: vi.fn(), encoding: "utf8" }));
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
readExternalSqlFileSnapshot: async (...args: unknown[]) => ({ content: await mocks.read(...args), version: { contentHash: "original", modifiedNs: "1", sizeBytes: 34 }, encoding: mocks.encoding }),
|
||||
writeExternalSqlFile: mocks.write,
|
||||
}));
|
||||
vi.mock("@/lib/common/clipboard", () => ({ copyToClipboard: mocks.copy, clipboardLineEndings: (text: string) => text }));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
|
||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => ({ editorSettings: { fontSize: 13, fontFamily: "monospace", theme: "app" } }) }));
|
||||
vi.mock("@/composables/useTheme", () => ({ useTheme: () => ({ isDark: { value: false }, themePalette: { value: "pearl" } }) }));
|
||||
vi.mock("@/lib/editor/editorThemes", () => ({ editorFontTheme: () => [], loadEditorTheme: async () => [] }));
|
||||
|
||||
import QuickOpenFilePreview from "../QuickOpenFilePreview.vue";
|
||||
|
||||
function item(path = "/sql/first.sql", line = 2, column = 8): QuickOpenItem {
|
||||
return { id: `${path}:${line}`, type: "content_match", connectionId: "", label: "first.sql", searchText: "needle", filePath: path, line, column, matchText: "needle" };
|
||||
}
|
||||
|
||||
const mounted: Array<{ app: App; host: HTMLElement }> = [];
|
||||
function mountPreview(selected = item(), events: { onNavigate?: (direction: -1 | 1) => void; onOpen?: () => void; onClose?: () => void; onEditing?: (value: boolean) => void } = {}) {
|
||||
const props = reactive({ item: selected });
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp({ render: () => h(QuickOpenFilePreview, { ...props, ...events }) });
|
||||
app.mount(host);
|
||||
mounted.push({ app, host });
|
||||
return { props, host, app };
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
await nextTick();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
function editor(host: HTMLElement): EditorView {
|
||||
const element = host.querySelector<HTMLElement>(".cm-editor");
|
||||
expect(element).not.toBeNull();
|
||||
return EditorView.findFromDOM(element!)!;
|
||||
}
|
||||
|
||||
function button(host: HTMLElement, label: string): HTMLButtonElement {
|
||||
const result = [...host.querySelectorAll("button")].find((node) => node.getAttribute("aria-label") === label || node.textContent?.trim() === label);
|
||||
expect(result).toBeDefined();
|
||||
return result!;
|
||||
}
|
||||
|
||||
async function beginEditing(host: HTMLElement): Promise<EditorView> {
|
||||
button(host, "common.edit").click();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
return editor(host);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mocks.read.mockReset().mockResolvedValue("-- heading\nSELECT needle;\nSELECT 2;");
|
||||
mocks.write.mockReset().mockResolvedValue({ kind: "written", version: { contentHash: "saved", modifiedNs: "2", sizeBytes: 35 } });
|
||||
mocks.copy.mockReset().mockResolvedValue(undefined);
|
||||
mocks.toast.mockReset();
|
||||
mocks.encoding = "utf8";
|
||||
});
|
||||
afterEach(() => {
|
||||
for (const { app, host } of mounted.splice(0)) {
|
||||
app.unmount();
|
||||
host.remove();
|
||||
}
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("quick-open file preview", () => {
|
||||
it("copies the selection, or the whole file when nothing is selected", async () => {
|
||||
const { host } = mountPreview();
|
||||
expect(button(host, "common.copy").disabled).toBe(true);
|
||||
await settle();
|
||||
button(host, "common.copy").click();
|
||||
await nextTick();
|
||||
expect(mocks.copy).toHaveBeenLastCalledWith("-- heading\nSELECT needle;\nSELECT 2;");
|
||||
const view = editor(host);
|
||||
const from = view.state.doc.line(2).from;
|
||||
view.dispatch({ selection: { anchor: from, head: from + 6 } });
|
||||
button(host, "common.copy").click();
|
||||
await nextTick();
|
||||
expect(mocks.copy).toHaveBeenLastCalledWith("SELECT");
|
||||
expect(mocks.toast).toHaveBeenCalledWith("grid.copied");
|
||||
});
|
||||
|
||||
it("reports clipboard failures and keeps the file available", async () => {
|
||||
mocks.copy.mockRejectedValue(new Error("denied"));
|
||||
const { host } = mountPreview();
|
||||
await settle();
|
||||
button(host, "common.copy").click();
|
||||
await nextTick();
|
||||
expect(mocks.toast).toHaveBeenCalledWith("grid.copyFailed", 5000);
|
||||
expect(editor(host).state.doc.toString()).toContain("needle");
|
||||
});
|
||||
|
||||
it("edits in place, supports undo/redo, and saves with the original version", async () => {
|
||||
const onEditing = vi.fn();
|
||||
const { host } = mountPreview(item(), { onEditing });
|
||||
await settle();
|
||||
const view = await beginEditing(host);
|
||||
expect(view.state.readOnly).toBe(false);
|
||||
expect(view.state.facet(EditorView.editable)).toBe(true);
|
||||
expect(onEditing).toHaveBeenLastCalledWith(true);
|
||||
view.dispatch({ changes: { from: view.state.doc.length, insert: "\nSELECT 3;" } });
|
||||
expect(undo(view)).toBe(true);
|
||||
expect(view.state.doc.toString()).not.toContain("SELECT 3;");
|
||||
expect(redo(view)).toBe(true);
|
||||
button(host, "common.save").click();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(mocks.write).toHaveBeenCalledWith("/sql/first.sql", "-- heading\nSELECT needle;\nSELECT 2;\nSELECT 3;", { expectedContentHash: "original", encoding: "utf8" });
|
||||
expect(editor(host)).toBe(view);
|
||||
expect(view.state.readOnly).toBe(true);
|
||||
expect(onEditing).toHaveBeenLastCalledWith(false);
|
||||
button(host, "common.copy").click();
|
||||
await nextTick();
|
||||
expect(mocks.copy).toHaveBeenLastCalledWith(view.state.sliceDoc());
|
||||
await beginEditing(host);
|
||||
view.dispatch({ changes: { from: view.state.doc.length, insert: "\n-- second edit" } });
|
||||
button(host, "common.save").click();
|
||||
await nextTick();
|
||||
expect(mocks.write).toHaveBeenLastCalledWith("/sql/first.sql", expect.stringContaining("second edit"), { expectedContentHash: "saved", encoding: "utf8" });
|
||||
});
|
||||
|
||||
it("restores the last saved content on cancel without writing a file", async () => {
|
||||
const { host } = mountPreview();
|
||||
await settle();
|
||||
const view = await beginEditing(host);
|
||||
const original = view.state.doc.toString();
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: "changed" } });
|
||||
button(host, "common.cancel").click();
|
||||
await nextTick();
|
||||
expect(view.state.doc.toString()).toBe(original);
|
||||
expect(view.state.readOnly).toBe(true);
|
||||
expect(mocks.write).not.toHaveBeenCalled();
|
||||
await beginEditing(host);
|
||||
expect(undo(view)).toBe(false);
|
||||
});
|
||||
|
||||
it("uses Enter and arrow keys for editing, and saves with Ctrl+S", async () => {
|
||||
const onOpen = vi.fn();
|
||||
const onNavigate = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const { host } = mountPreview(item(), { onOpen, onNavigate, onClose });
|
||||
await settle();
|
||||
const view = await beginEditing(host);
|
||||
view.contentDOM.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
view.contentDOM.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true }));
|
||||
view.contentDOM.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }));
|
||||
expect(onOpen).not.toHaveBeenCalled();
|
||||
expect(onNavigate).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(view.state.doc.lines).toBe(4);
|
||||
view.focus();
|
||||
view.contentDOM.dispatchEvent(new KeyboardEvent("keydown", { key: "s", ctrlKey: true, bubbles: true, cancelable: true }));
|
||||
await nextTick();
|
||||
expect(mocks.write).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each(["conflict", "missing", "error"])("keeps the draft when saving returns %s", async (kind) => {
|
||||
if (kind === "error") mocks.write.mockRejectedValue(new Error("write denied"));
|
||||
else mocks.write.mockResolvedValue({ kind });
|
||||
const { host } = mountPreview();
|
||||
await settle();
|
||||
const view = await beginEditing(host);
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: "my draft" } });
|
||||
button(host, "common.save").click();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(host.querySelector('[role="alert"]')).not.toBeNull();
|
||||
expect(view.state.doc.toString()).toBe("my draft");
|
||||
expect(view.state.readOnly).toBe(false);
|
||||
expect(button(host, "common.cancel").disabled).toBe(false);
|
||||
expect(mocks.write).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("disables editing and prevents duplicate saves while a write is pending", async () => {
|
||||
let resolveWrite!: (value: unknown) => void;
|
||||
mocks.write.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveWrite = resolve;
|
||||
}),
|
||||
);
|
||||
const { host } = mountPreview();
|
||||
await settle();
|
||||
const view = await beginEditing(host);
|
||||
view.dispatch({ changes: { from: 0, insert: "-- draft\n" } });
|
||||
button(host, "common.save").click();
|
||||
await nextTick();
|
||||
expect(view.state.readOnly).toBe(true);
|
||||
expect(button(host, "common.save").disabled).toBe(true);
|
||||
expect(button(host, "common.cancel").disabled).toBe(true);
|
||||
view.contentDOM.dispatchEvent(new KeyboardEvent("keydown", { key: "s", ctrlKey: true, bubbles: true, cancelable: true }));
|
||||
expect(mocks.write).toHaveBeenCalledOnce();
|
||||
resolveWrite({ kind: "written", version: { contentHash: "saved", modifiedNs: "2", sizeBytes: 44 } });
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(button(host, "common.edit")).toBeDefined();
|
||||
});
|
||||
|
||||
it.each(["\r\n", "\n", "\r"])("preserves %j line endings and the detected encoding", async (lineEnding) => {
|
||||
mocks.encoding = "utf16le";
|
||||
mocks.read.mockResolvedValue(`SELECT 1;${lineEnding}SELECT 2;`);
|
||||
const { host } = mountPreview();
|
||||
await settle();
|
||||
const view = await beginEditing(host);
|
||||
view.dispatch({ changes: { from: view.state.doc.length, insert: "\nSELECT 3;" } });
|
||||
button(host, "common.save").click();
|
||||
await nextTick();
|
||||
expect(mocks.write).toHaveBeenCalledWith("/sql/first.sql", `SELECT 1;${lineEnding}SELECT 2;${lineEnding}SELECT 3;`, { expectedContentHash: "original", encoding: "utf16le" });
|
||||
});
|
||||
|
||||
it("allows editing and saving an initially empty file", async () => {
|
||||
mocks.read.mockResolvedValue("");
|
||||
const { host } = mountPreview();
|
||||
await settle();
|
||||
const view = await beginEditing(host);
|
||||
view.dispatch({ changes: { from: 0, insert: "SELECT 1;" } });
|
||||
button(host, "common.save").click();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(editor(host).state.doc.toString()).toBe("SELECT 1;");
|
||||
expect(host.textContent).not.toContain("quickOpen.previewEmpty");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Escape", "close", undefined],
|
||||
["Enter", "open", undefined],
|
||||
["ArrowUp", "navigate", -1],
|
||||
["ArrowDown", "navigate", 1],
|
||||
] as const)("forwards %s from the focused editor to the search dialog", async (key, event, direction) => {
|
||||
const onNavigate = vi.fn();
|
||||
const onOpen = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const { host } = mountPreview(item(), { onNavigate, onOpen, onClose });
|
||||
await settle();
|
||||
const view = editor(host);
|
||||
view.focus();
|
||||
const keyboardEvent = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
|
||||
view.contentDOM.dispatchEvent(keyboardEvent);
|
||||
expect(keyboardEvent.defaultPrevented).toBe(true);
|
||||
if (event === "navigate") expect(onNavigate).toHaveBeenCalledWith(direction);
|
||||
else expect(event === "open" ? onOpen : onClose).toHaveBeenCalledOnce();
|
||||
expect(view.state.doc.toString()).toBe("-- heading\nSELECT needle;\nSELECT 2;");
|
||||
});
|
||||
|
||||
it("keeps shifted arrow selection available for copying preview text", async () => {
|
||||
const onNavigate = vi.fn();
|
||||
const { host } = mountPreview(item(), { onNavigate });
|
||||
await settle();
|
||||
const view = editor(host);
|
||||
view.focus();
|
||||
view.contentDOM.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", shiftKey: true, bubbles: true, cancelable: true }));
|
||||
expect(view.state.selection.main.empty).toBe(false);
|
||||
expect(onNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["😀 prefix ", "😀🧪 前缀 "])("maps character columns to UTF-16 after %s", async (prefix) => {
|
||||
mocks.read.mockResolvedValue(`${prefix}needle`);
|
||||
const { host } = mountPreview(item(undefined, 1, [...prefix].length + 1));
|
||||
await settle();
|
||||
expect(editor(host).state.selection.main.head).toBe(prefix.length);
|
||||
expect(host.querySelector(".cm-preview-match")?.textContent).toBe("needle");
|
||||
});
|
||||
|
||||
it("reads the full file, shows the selected hit, and keeps the editor read-only", async () => {
|
||||
const { host } = mountPreview();
|
||||
expect(host.textContent).toContain("common.loading");
|
||||
await settle();
|
||||
const view = editor(host);
|
||||
expect(mocks.read).toHaveBeenCalledWith("/sql/first.sql", 32 * 1024 * 1024);
|
||||
expect(view.state.doc.toString()).toContain("SELECT 2;");
|
||||
expect(view.state.facet(EditorState.readOnly)).toBe(true);
|
||||
expect(view.state.facet(EditorView.editable)).toBe(false);
|
||||
expect(view.state.selection.main.head).toBe(view.state.doc.line(2).from + 7);
|
||||
expect(host.querySelector(".cm-preview-match")?.textContent).toBe("needle");
|
||||
expect(view.contentDOM.getAttribute("aria-label")).toBe("/sql/first.sql");
|
||||
});
|
||||
|
||||
it("moves to another line of the same file without reading again", async () => {
|
||||
const { host, props } = mountPreview();
|
||||
await settle();
|
||||
const firstEditor = editor(host);
|
||||
props.item = { ...item(undefined, 3, 8), matchText: "2" };
|
||||
await nextTick();
|
||||
expect(editor(host)).toBe(firstEditor);
|
||||
expect(editor(host).state.selection.main.head).toBe(firstEditor.state.doc.line(3).from + 7);
|
||||
expect(host.querySelector(".cm-preview-match")?.textContent).toBe("2");
|
||||
expect(mocks.read).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("debounces rapid file selection and ignores a stale read", async () => {
|
||||
let resolveFirst!: (text: string) => void;
|
||||
mocks.read.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
}),
|
||||
);
|
||||
const { host, props } = mountPreview();
|
||||
await settle();
|
||||
props.item = item("/sql/skipped.sql");
|
||||
await nextTick();
|
||||
props.item = item("/sql/latest.sql", 1, 1);
|
||||
mocks.read.mockResolvedValue("latest");
|
||||
await settle();
|
||||
resolveFirst("stale");
|
||||
await settle();
|
||||
expect(mocks.read.mock.calls.map(([path]) => path)).toEqual(["/sql/first.sql", "/sql/latest.sql"]);
|
||||
expect(editor(host).state.doc.toString()).toBe("latest");
|
||||
});
|
||||
|
||||
it.each(["", null])("shows a readable state for an empty or unavailable file: %s", async (text) => {
|
||||
if (text === null) mocks.read.mockRejectedValue(new Error("private path details"));
|
||||
else mocks.read.mockResolvedValue(text);
|
||||
const { host } = mountPreview();
|
||||
await settle();
|
||||
expect(host.textContent).toContain(text === null ? "quickOpen.previewFailed" : "quickOpen.previewEmpty");
|
||||
expect(host.textContent).not.toContain("private path details");
|
||||
expect(host.querySelector(".cm-editor")).toBeNull();
|
||||
});
|
||||
|
||||
it("previews filename-only hits from the beginning and clamps outdated locations", async () => {
|
||||
const { host, props } = mountPreview(item(undefined, 0));
|
||||
await settle();
|
||||
expect(editor(host).state.selection.main.head).toBe(0);
|
||||
expect(host.querySelector(".cm-preview-match")).toBeNull();
|
||||
props.item = item(undefined, 999, 999);
|
||||
await nextTick();
|
||||
expect(editor(host).state.selection.main.head).toBe(editor(host).state.doc.length);
|
||||
});
|
||||
|
||||
it("cancels a pending read when the preview closes", async () => {
|
||||
const { app } = mountPreview();
|
||||
app.unmount();
|
||||
await settle();
|
||||
expect(mocks.read).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2811,6 +2811,10 @@ export default withEnglishFallback({
|
||||
inputPlaceholder: "YYYY-MM-DD HH:mm:ss",
|
||||
},
|
||||
quickOpen: {
|
||||
finishEditing: "Axtarışa davam etməzdən və ya bağlamazdan əvvəl önbaxış dəyişikliklərini yadda saxlayın və ya ləğv edin.",
|
||||
filePreview: "Fayla önbaxış",
|
||||
previewFailed: "Bu fayla önbaxış mümkün deyil. Məzmunu görmək üçün faylı açın.",
|
||||
previewEmpty: "Fayl boşdur",
|
||||
placeholder: "Əlaqələri, verilənlər bazalarını, cədvəlləri, SQL fayllarını və digər obyektləri axtar...",
|
||||
emptyPlaceholder: "Axtarış üçün yazmağa başlayın",
|
||||
noResults: "Nəticə tapılmadı",
|
||||
@@ -6845,6 +6849,8 @@ export default withEnglishFallback({
|
||||
deleteFolderConfirm: "{name} və filtrin gizlətdiyi fayllar daxil olmaqla bütün məzmun silinsin? Bu əməliyyat geri qaytarıla bilməz.",
|
||||
folderDeleted: "Qovluq silindi",
|
||||
deleteFolderFailed: "Qovluq silinə bilmədi: {message}",
|
||||
locateActiveFile: "Cari faylı tap",
|
||||
activeFileNotFound: "Cari fayl tapılmadı. Faylın mövcud olduğunu və ya fayl filtri ilə gizlədilmədiyini yoxlayın.",
|
||||
title: "SQL faylları",
|
||||
storageHelp: "Bunlar Qovluq aç ilə açılan diskdəki fayllardır. Standart olaraq .sql və .ddl faylları göstərilir; fayl süzgəcini dəyişə bilərsiniz. Düzəlişlər birbaşa orijinal fayla yazılır.",
|
||||
openFolder: "Qovluq aç",
|
||||
|
||||
@@ -3624,6 +3624,10 @@ export default {
|
||||
inputPlaceholder: "YYYY-MM-DD HH:mm:ss",
|
||||
},
|
||||
quickOpen: {
|
||||
finishEditing: "Save or cancel preview edits before continuing your search or closing.",
|
||||
filePreview: "File preview",
|
||||
previewFailed: "Unable to preview this file. Open it to view its contents.",
|
||||
previewEmpty: "This file is empty",
|
||||
placeholder: "Search connections, databases, tables, SQL files, and other objects...",
|
||||
emptyPlaceholder: "Start typing to search",
|
||||
noResults: "No results found",
|
||||
@@ -8203,6 +8207,8 @@ export default {
|
||||
deleteFolderConfirm: "Delete {name} and all its contents, including files hidden by the filter? This cannot be undone.",
|
||||
folderDeleted: "Folder deleted",
|
||||
deleteFolderFailed: "Failed to delete folder: {message}",
|
||||
locateActiveFile: "Locate current file",
|
||||
activeFileNotFound: "Current file not found. Check whether it exists or is hidden by the file filter.",
|
||||
title: "SQL Files",
|
||||
storageHelp: "These are files on disk opened via Open Folder. By default, .sql and .ddl files are shown; you can customize the file filter. Edits are saved straight back to the original file.",
|
||||
openFolder: "Open Folder",
|
||||
|
||||
@@ -3383,6 +3383,10 @@ export default withEnglishFallback({
|
||||
inputPlaceholder: "AAAA-MM-DD HH:mm:ss",
|
||||
},
|
||||
quickOpen: {
|
||||
finishEditing: "Guarda o cancela los cambios de la vista previa antes de seguir buscando o cerrar.",
|
||||
filePreview: "Vista previa del archivo",
|
||||
previewFailed: "No se puede previsualizar este archivo. Ábrelo para ver su contenido.",
|
||||
previewEmpty: "Este archivo está vacío",
|
||||
placeholder: "Buscar conexiones, bases de datos, tablas, archivos SQL y otros objetos...",
|
||||
emptyPlaceholder: "Comienza a escribir para buscar",
|
||||
noResults: "No se encontraron resultados",
|
||||
@@ -10259,6 +10263,8 @@ export default withEnglishFallback({
|
||||
deleteFolderConfirm: "¿Eliminar {name} y todo su contenido, incluidos los archivos ocultos por el filtro? Esta acción no se puede deshacer.",
|
||||
folderDeleted: "Carpeta eliminada",
|
||||
deleteFolderFailed: "No se pudo eliminar la carpeta: {message}",
|
||||
locateActiveFile: "Localizar archivo actual",
|
||||
activeFileNotFound: "No se encontró el archivo actual. Comprueba si existe o si el filtro de archivos lo oculta.",
|
||||
title: "Archivos SQL",
|
||||
storageHelp: "Son archivos del disco abiertos con Abrir carpeta. De forma predeterminada se muestran archivos .sql y .ddl; puede personalizar el filtro. Las ediciones se guardan directamente en el archivo original.",
|
||||
openFolder: "Abrir carpeta",
|
||||
|
||||
@@ -3139,6 +3139,10 @@ export default withEnglishFallback({
|
||||
inputPlaceholder: "YYYY-MM-DD HH:mm:ss",
|
||||
},
|
||||
quickOpen: {
|
||||
finishEditing: "Simpan atau batalkan perubahan pratinjau sebelum melanjutkan pencarian atau menutup.",
|
||||
filePreview: "Pratinjau berkas",
|
||||
previewFailed: "Berkas ini tidak dapat dipratinjau. Buka berkas untuk melihat isinya.",
|
||||
previewEmpty: "Berkas ini kosong",
|
||||
placeholder: "Cari koneksi, database, tabel, file SQL, dan objek lainnya...",
|
||||
emptyPlaceholder: "Mulai mengetik untuk mencari",
|
||||
noResults: "Tidak ada hasil ditemukan",
|
||||
|
||||
@@ -3235,6 +3235,10 @@ export default withEnglishFallback({
|
||||
inputPlaceholder: "AAAA-MM-GG HH:mm:ss",
|
||||
},
|
||||
quickOpen: {
|
||||
finishEditing: "Salva o annulla le modifiche all’anteprima prima di continuare la ricerca o chiudere.",
|
||||
filePreview: "Anteprima del file",
|
||||
previewFailed: "Impossibile visualizzare l’anteprima. Apri il file per vedere il contenuto.",
|
||||
previewEmpty: "Il file è vuoto",
|
||||
placeholder: "Cerca connessioni, database, tabelle, file SQL e altri oggetti...",
|
||||
emptyPlaceholder: "Inizia a digitare per cercare",
|
||||
noResults: "Nessun risultato trovato",
|
||||
@@ -10007,6 +10011,8 @@ export default withEnglishFallback({
|
||||
deleteFolderConfirm: "Eliminare {name} e tutto il contenuto, inclusi i file nascosti dal filtro? Questa azione è irreversibile.",
|
||||
folderDeleted: "Cartella eliminata",
|
||||
deleteFolderFailed: "Eliminazione della cartella non riuscita: {message}",
|
||||
locateActiveFile: "Trova il file corrente",
|
||||
activeFileNotFound: "File corrente non trovato. Verifica che esista o che non sia nascosto dal filtro dei file.",
|
||||
title: "File SQL",
|
||||
storageHelp: "Sono file sul disco aperti con Apri cartella. Per impostazione predefinita vengono mostrati i file .sql e .ddl; puoi personalizzare il filtro. Le modifiche vengono salvate direttamente nel file originale.",
|
||||
openFolder: "Apri cartella",
|
||||
|
||||
@@ -3259,6 +3259,10 @@ export default withEnglishFallback({
|
||||
inputPlaceholder: "YYYY-MM-DD HH:mm:ss",
|
||||
},
|
||||
quickOpen: {
|
||||
finishEditing: "検索を続けるか閉じる前に、プレビューの編集を保存またはキャンセルしてください。",
|
||||
filePreview: "ファイルプレビュー",
|
||||
previewFailed: "このファイルをプレビューできません。ファイルを開いて内容を確認してください。",
|
||||
previewEmpty: "ファイルは空です",
|
||||
placeholder: "接続、データベース、テーブル、SQLファイル、その他のオブジェクトを検索...",
|
||||
emptyPlaceholder: "入力して検索を開始",
|
||||
noResults: "結果が見つかりません",
|
||||
@@ -10043,6 +10047,8 @@ export default withEnglishFallback({
|
||||
deleteFolderConfirm: "{name} とその内容をすべて削除しますか?フィルターで非表示のファイルも含まれます。この操作は取り消せません。",
|
||||
folderDeleted: "フォルダーを削除しました",
|
||||
deleteFolderFailed: "フォルダーの削除に失敗しました: {message}",
|
||||
locateActiveFile: "現在のファイルを表示",
|
||||
activeFileNotFound: "現在のファイルが見つかりません。ファイルが存在するか、ファイルフィルターで非表示になっていないか確認してください。",
|
||||
title: "SQL ファイル",
|
||||
storageHelp: "「フォルダーを開く」で開いたディスク上のファイルを表示します。既定では.sqlと.ddlファイルが表示され、フィルター設定で変更できます。編集内容は元のファイルに直接保存されます。",
|
||||
openFolder: "フォルダーを開く",
|
||||
|
||||
@@ -3185,6 +3185,10 @@ export default withEnglishFallback({
|
||||
inputPlaceholder: "YYYY-MM-DD HH:mm:ss",
|
||||
},
|
||||
quickOpen: {
|
||||
finishEditing: "검색을 계속하거나 닫기 전에 미리보기 편집을 저장하거나 취소하세요.",
|
||||
filePreview: "파일 미리 보기",
|
||||
previewFailed: "이 파일을 미리 볼 수 없습니다. 파일을 열어 내용을 확인하세요.",
|
||||
previewEmpty: "파일이 비어 있습니다",
|
||||
placeholder: "연결, 데이터베이스, 테이블, SQL 파일 및 다른 객체 검색...",
|
||||
emptyPlaceholder: "검색하려면 입력을 시작하세요",
|
||||
noResults: "결과를 찾을 수 없습니다",
|
||||
@@ -7004,6 +7008,8 @@ export default withEnglishFallback({
|
||||
deleteFolderConfirm: "{name} 및 모든 내용을 삭제할까요? 필터로 숨겨진 파일도 포함됩니다. 이 작업은 되돌릴 수 없습니다.",
|
||||
folderDeleted: "폴더를 삭제했습니다",
|
||||
deleteFolderFailed: "폴더 삭제 실패: {message}",
|
||||
locateActiveFile: "현재 파일 찾기",
|
||||
activeFileNotFound: "현재 파일을 찾을 수 없습니다. 파일이 존재하는지 또는 파일 필터로 숨겨졌는지 확인하세요.",
|
||||
title: "SQL 파일",
|
||||
storageHelp: "폴더 열기로 연 디스크의 파일을 표시합니다. 기본적으로 .sql 및 .ddl 파일이 표시되며 필터 설정에서 변경할 수 있습니다. 편집 내용은 원본 파일에 바로 저장됩니다.",
|
||||
openFolder: "폴더 열기",
|
||||
|
||||
@@ -3235,6 +3235,10 @@ export default withEnglishFallback({
|
||||
inputPlaceholder: "AAAA-MM-DD HH:mm:ss",
|
||||
},
|
||||
quickOpen: {
|
||||
finishEditing: "Salve ou cancele as alterações da prévia antes de continuar a pesquisa ou fechar.",
|
||||
filePreview: "Prévia do arquivo",
|
||||
previewFailed: "Não foi possível visualizar este arquivo. Abra-o para ver o conteúdo.",
|
||||
previewEmpty: "Este arquivo está vazio",
|
||||
placeholder: "Pesquisar conexões, bancos de dados, tabelas, arquivos SQL e outros objetos...",
|
||||
emptyPlaceholder: "Comece a digitar para pesquisar",
|
||||
noResults: "Nenhum resultado encontrado",
|
||||
@@ -10003,6 +10007,8 @@ export default withEnglishFallback({
|
||||
deleteFolderConfirm: "Excluir {name} e todo o conteúdo, incluindo arquivos ocultos pelo filtro? Esta ação não pode ser desfeita.",
|
||||
folderDeleted: "Pasta excluída",
|
||||
deleteFolderFailed: "Falha ao excluir pasta: {message}",
|
||||
locateActiveFile: "Localizar arquivo atual",
|
||||
activeFileNotFound: "Arquivo atual não encontrado. Verifique se ele existe ou está oculto pelo filtro de arquivos.",
|
||||
title: "Arquivos SQL",
|
||||
storageHelp: "São arquivos no disco abertos com Abrir pasta. Por padrão, são exibidos arquivos .sql e .ddl; você pode personalizar o filtro. As edições são salvas diretamente no arquivo original.",
|
||||
openFolder: "Abrir pasta",
|
||||
|
||||
@@ -3336,6 +3336,10 @@ export default withEnglishFallback({
|
||||
inputPlaceholder: "ГГГГ-ММ-ДД ЧЧ:мм:сс",
|
||||
},
|
||||
quickOpen: {
|
||||
finishEditing: "Сохраните или отмените изменения в предпросмотре, прежде чем продолжить поиск или закрыть окно.",
|
||||
filePreview: "Предпросмотр файла",
|
||||
previewFailed: "Не удалось показать файл. Откройте его для просмотра содержимого.",
|
||||
previewEmpty: "Файл пуст",
|
||||
placeholder: "Поиск подключений, баз данных, таблиц, файлов SQL и других объектов...",
|
||||
emptyPlaceholder: "Начните вводить текст для поиска",
|
||||
noResults: "Ничего не найдено",
|
||||
@@ -8450,6 +8454,8 @@ export default withEnglishFallback({
|
||||
deleteFolderConfirm: "Удалить {name} и всё содержимое, включая скрытые фильтром файлы? Это действие нельзя отменить.",
|
||||
folderDeleted: "Папка удалена",
|
||||
deleteFolderFailed: "Не удалось удалить папку: {message}",
|
||||
locateActiveFile: "Найти текущий файл",
|
||||
activeFileNotFound: "Текущий файл не найден. Проверьте, существует ли он и не скрыт ли фильтром файлов.",
|
||||
title: "SQL-файлы",
|
||||
storageHelp: "Это файлы на диске, открытые через «Открыть папку». По умолчанию отображаются файлы .sql и .ddl; фильтр можно настроить. Изменения сохраняются прямо в исходный файл.",
|
||||
openFolder: "Открыть папку",
|
||||
|
||||
@@ -3570,6 +3570,10 @@ export default withEnglishFallback({
|
||||
inputPlaceholder: "YYYY-MM-DD HH:mm:ss",
|
||||
},
|
||||
quickOpen: {
|
||||
finishEditing: "บันทึกหรือยกเลิกการแก้ไขตัวอย่างก่อนค้นหาต่อหรือปิด",
|
||||
filePreview: "ตัวอย่างไฟล์",
|
||||
previewFailed: "ไม่สามารถแสดงตัวอย่างไฟล์นี้ได้ เปิดไฟล์เพื่อดูเนื้อหา",
|
||||
previewEmpty: "ไฟล์นี้ว่างเปล่า",
|
||||
placeholder: "ค้นหาการเชื่อมต่อ ฐานข้อมูล ตาราง ไฟล์ SQL และออบเจ็กต์อื่น...",
|
||||
emptyPlaceholder: "เริ่มพิมพ์เพื่อค้นหา",
|
||||
noResults: "ไม่พบผลลัพธ์",
|
||||
@@ -8839,6 +8843,8 @@ export default withEnglishFallback({
|
||||
totalFiles: "ไฟล์ {count} ไฟล์",
|
||||
},
|
||||
sqlFileTree: {
|
||||
locateActiveFile: "ค้นหาตำแหน่งไฟล์ปัจจุบัน",
|
||||
activeFileNotFound: "ไม่พบไฟล์ปัจจุบัน ตรวจสอบว่าไฟล์มีอยู่หรือถูกซ่อนโดยตัวกรองไฟล์หรือไม่",
|
||||
title: "ไฟล์ SQL",
|
||||
folderNameInvalid: "ป้อนชื่อโฟลเดอร์เดียว ไม่ใช่เส้นทาง",
|
||||
folderNameExcluded: "ชื่อโฟลเดอร์นี้ถูกยกเว้นจากตัวเรียกดูไฟล์ SQL",
|
||||
|
||||
@@ -2787,6 +2787,10 @@ export default withEnglishFallback({
|
||||
inputPlaceholder: "YYYY-MM-DD HH:mm:ss",
|
||||
},
|
||||
quickOpen: {
|
||||
finishEditing: "Aramaya devam etmeden veya kapatmadan önce önizleme değişikliklerini kaydedin ya da iptal edin.",
|
||||
filePreview: "Dosya önizlemesi",
|
||||
previewFailed: "Bu dosya önizlenemiyor. İçeriğini görmek için dosyayı açın.",
|
||||
previewEmpty: "Bu dosya boş",
|
||||
placeholder: "Bağlantı, veritabanı, tablo, SQL dosyası ve diğer nesnelerde ara...",
|
||||
emptyPlaceholder: "Aramak için yazmaya başlayın",
|
||||
noResults: "Sonuç bulunamadı",
|
||||
@@ -6726,6 +6730,8 @@ export default withEnglishFallback({
|
||||
deleteFolderConfirm: "{name} ve filtreyle gizlenen dosyalar dahil tüm içeriği silinsin mi? Bu işlem geri alınamaz.",
|
||||
folderDeleted: "Klasör silindi",
|
||||
deleteFolderFailed: "Klasör silinemedi: {message}",
|
||||
locateActiveFile: "Geçerli dosyayı bul",
|
||||
activeFileNotFound: "Geçerli dosya bulunamadı. Dosyanın var olup olmadığını veya dosya filtresi tarafından gizlenip gizlenmediğini kontrol edin.",
|
||||
title: "SQL Dosyaları",
|
||||
storageHelp: "Klasör Aç ile açılan diskteki dosyalardır. Varsayılan olarak .sql ve .ddl dosyaları gösterilir; dosya filtresini özelleştirebilirsiniz. Düzenlemeler doğrudan özgün dosyaya kaydedilir.",
|
||||
openFolder: "Klasör Aç",
|
||||
|
||||
@@ -3557,6 +3557,10 @@ export default withEnglishFallback({
|
||||
inputPlaceholder: "YYYY-MM-DD HH:mm:ss",
|
||||
},
|
||||
quickOpen: {
|
||||
finishEditing: "请先保存或取消预览编辑,再继续搜索或关闭。",
|
||||
filePreview: "文件预览",
|
||||
previewFailed: "无法预览此文件,请打开文件查看",
|
||||
previewEmpty: "文件内容为空",
|
||||
placeholder: "搜索连接、数据库、表、SQL文件和其他对象...",
|
||||
emptyPlaceholder: "开始输入进行搜索",
|
||||
noResults: "未找到结果",
|
||||
@@ -8185,6 +8189,8 @@ export default withEnglishFallback({
|
||||
deleteFolderConfirm: "确定删除 {name} 及其全部内容吗?包含被过滤规则隐藏的文件,此操作无法撤销。",
|
||||
folderDeleted: "文件夹已删除",
|
||||
deleteFolderFailed: "删除文件夹失败:{message}",
|
||||
locateActiveFile: "定位当前文件",
|
||||
activeFileNotFound: "未找到当前文件,请检查文件是否存在或被文件筛选隐藏",
|
||||
title: "SQL 文件",
|
||||
storageHelp: "此处列出通过「打开文件夹」打开的磁盘文件,默认显示 .sql 和 .ddl 文件,可通过过滤设置调整;编辑后直接保存回原文件。",
|
||||
openFolder: "打开文件夹",
|
||||
|
||||
@@ -3239,6 +3239,10 @@ export default withEnglishFallback({
|
||||
inputPlaceholder: "YYYY-MM-DD HH:mm:ss",
|
||||
},
|
||||
quickOpen: {
|
||||
finishEditing: "請先儲存或取消預覽編輯,再繼續搜尋或關閉。",
|
||||
filePreview: "檔案預覽",
|
||||
previewFailed: "無法預覽此檔案,請開啟檔案查看",
|
||||
previewEmpty: "檔案內容為空",
|
||||
placeholder: "搜尋連線、資料庫、資料表、SQL檔案和其他物件……",
|
||||
emptyPlaceholder: "開始輸入進行搜尋",
|
||||
noResults: "找不到結果",
|
||||
@@ -10014,6 +10018,8 @@ export default withEnglishFallback({
|
||||
deleteFolderConfirm: "確定刪除 {name} 及其全部內容嗎?包含被篩選規則隱藏的檔案,此操作無法復原。",
|
||||
folderDeleted: "資料夾已刪除",
|
||||
deleteFolderFailed: "刪除資料夾失敗:{message}",
|
||||
locateActiveFile: "定位目前檔案",
|
||||
activeFileNotFound: "找不到目前檔案,請檢查檔案是否存在或被檔案篩選隱藏",
|
||||
title: "SQL 檔案",
|
||||
storageHelp: "此處列出透過「開啟資料夾」開啟的磁碟檔案,預設顯示 .sql 和 .ddl 檔案,可透過篩選設定調整;編輯後直接儲存回原檔案。",
|
||||
openFolder: "開啟資料夾",
|
||||
|
||||
@@ -5,6 +5,12 @@ description: 使用 DBX 编写、理解、参数化、执行、分析和复用 S
|
||||
|
||||
查询编辑器是 DBX 的 SQL 工作区。它把方言感知的高亮与补全、语义诊断、参数输入、执行进度、多结果、执行计划、查询历史、SQL 库和 AI 助手放在同一个标签页中。
|
||||
|
||||
## 全局文件搜索预览
|
||||
|
||||
桌面版的全局文件内容搜索会在结果列表下方预览当前选中的文件。使用上下方向键或鼠标悬停切换结果,预览会显示文件路径、行号并定位和高亮匹配内容。点击预览工具栏的复制按钮可复制选中文本,没有选中时复制全文。单击结果或按回车打开文件。在只读预览区域内按上下键仍可切换结果,回车打开当前结果,Esc 关闭搜索窗口。仅文件名匹配时从文件开头预览。空文件和读取失败会显示相应提示。
|
||||
|
||||
点击预览工具栏的编辑按钮,可直接修改文件,包括空文件。编辑时,回车和方向键用于文本编辑,支持撤销与重做;点击保存或按 `Mod+S` 写入文件,点击取消放弃本次修改。保存或取消前,当前预览文件保持固定,搜索、打开其他结果和关闭搜索窗口暂不可用。保存保留检测到的编码和换行格式;如果文件已被其他程序修改、删除或保存失败,DBX 会保留编辑内容并显示错误,不会自动覆盖外部修改。
|
||||
|
||||
## Vim 配置
|
||||
|
||||
在桌面版的**编辑器设置**中启用 Vim 模式后,DBX 会读取 `~/.dbx/vimrc`。如文件不存在,请先创建;修改后重启 DBX 才会生效。空行及以 `"` 开头的注释行会被忽略。CodeMirror Vim 支持的命令会应用到每个 SQL 编辑器标签页,例如:
|
||||
@@ -198,6 +204,8 @@ set clipboard=unnamedplus
|
||||
|
||||
保存 SQL 时可以搜索或创建文件夹。快速打开会同时索引 SQL 库和 SQL 文件。
|
||||
|
||||
桌面端 SQL 文件面板顶部的“定位当前文件”按钮会展开父目录、选中当前打开的文件并滚动到其位置。如果所在目录尚未打开,会先将其加入文件树;未保存的查询无法定位。文件筛选仍然生效,被隐藏或已不存在的文件会显示提示。
|
||||
|
||||
## 安全与边界
|
||||
|
||||
- 只读连接会在核心执行路径拒绝可识别写入
|
||||
|
||||
@@ -5,6 +5,12 @@ description: Write, understand, parameterize, execute, analyze, and reuse SQL in
|
||||
|
||||
The query editor is the SQL workspace in DBX. It combines dialect-aware highlighting and completion, semantic diagnostics, parameter input, execution progress, multiple results, execution plans, query history, the SQL library, and AI assistance in one tab.
|
||||
|
||||
## Global file search preview
|
||||
|
||||
In the desktop app, global file content search previews the selected file below the results. Use the up and down arrow keys or hover to select a result. The preview shows its path and line numbers and scrolls to the highlighted match. Use the toolbar copy button to copy selected text, or the whole file when nothing is selected. Click a result or press Enter to open the file. While focused in the read-only preview, the arrow keys still navigate results, Enter opens the selected result, and Escape closes the search dialog. Filename-only matches preview the beginning of the file. Empty files and read failures show an appropriate message.
|
||||
|
||||
Use the preview toolbar edit button to change the file in place, including empty files. While editing, Enter and arrow keys edit text, and undo and redo are available. Click Save or press `Mod+S` to write the file; Cancel discards the current edits. The preview stays on the same file until you save or cancel, with searching, opening other results, and closing the dialog temporarily unavailable. Saving preserves the detected encoding and line endings. If another program has modified or deleted the file, or saving fails, DBX keeps your draft and shows an error without automatically overwriting external changes.
|
||||
|
||||
## Vim configuration
|
||||
|
||||
In the desktop app, enable Vim mode in **Editor Settings** to load commands from `~/.dbx/vimrc`. Create the file if needed, then restart DBX after editing it. Blank lines and lines beginning with `"` are ignored. Commands supported by CodeMirror Vim, such as these mappings, apply to every SQL editor tab:
|
||||
@@ -198,6 +204,8 @@ Query history stores executed SQL per connection with time and context. You can:
|
||||
|
||||
The save dialog can search or create folders. Quick Open indexes both SQL Library entries and SQL Files.
|
||||
|
||||
On Desktop, the **Locate current file** button in the SQL Files panel expands parent directories, selects the current file, and scrolls it into view. If its directory is not open yet, it is added to the file tree first. Unsaved queries cannot be located. File filters remain in effect; hidden or missing files show a notification.
|
||||
|
||||
## Safety and Boundaries
|
||||
|
||||
- Read-only connections reject recognizable writes in the core execution path
|
||||
|
||||
Reference in New Issue
Block a user