feat(sql-library): support query copy/paste and rename focus

This commit is contained in:
二丫讲梵
2026-10-05 13:42:43 +08:00
committed by GitHub
parent f19b62be0e
commit c3c53fee9a
4 changed files with 189 additions and 8 deletions
@@ -10,6 +10,8 @@ import {
ArrowUpDown,
Calendar,
ChevronsDownUp,
Clipboard,
Copy,
Clock,
Database,
Download,
@@ -41,6 +43,7 @@ import SqlLibrarySearchSnippet from "@/components/layout/SqlLibrarySearchSnippet
import type { SavedSqlLineMatch } from "@/lib/savedSql/savedSqlSearch";
import { useToast } from "@/composables/useToast";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { copyToClipboard } from "@/lib/common/clipboard";
import * as api from "@/lib/backend/api";
import { externalSqlFileOpenErrorMessage } from "@/lib/sql/sqlFileOpen";
import { useSavedSqlStore, type SavedSqlFileSortMode, type SavedSqlFolderSortMode } from "@/stores/savedSqlStore";
@@ -495,6 +498,9 @@ async function openNewFolderInput(parentFolderId?: string) {
async function openNewQueryInFolder(folder?: SavedSqlFolder) {
const connectionId = folder?.connectionId || connectionStore.activeConnectionId || connectionStore.connections[0]?.id;
if (!connectionId) return;
if (folder?.id) {
collapsedFolders.value = new Set([...collapsedFolders.value].filter((id) => id !== folder.id));
}
const folderFiles = folder ? savedSqlStore.filesInFolder(folder.id) : [];
const folderFallbackDb = folderFiles.find((f) => f.database)?.database;
@@ -518,6 +524,9 @@ async function openNewQueryInFolder(folder?: SavedSqlFolder) {
});
const tabId = queryStore.openSavedSql(file);
connectionStore.activeConnectionId = queryStore.tabs.find((tab) => tab.id === tabId)?.connectionId ?? file.connectionId;
// New files should behave like newly-created folders: the default name is
// immediately editable, with the extension excluded from the selection.
startRenameFile(file, { restoreFocusAfterEditorMount: true });
} catch (error) {
toast(t("savedSql.saveFailed", { message: savedSqlErrorMessage(error, t) }), 5000);
}
@@ -801,6 +810,26 @@ function fileTitleStyle(file: SavedSqlFile): CSSProperties | undefined {
const renamingTarget = ref<{ type: "folder" | "file"; id: string } | null>(null);
const renameValue = ref("");
const renameInputRef = ref<HTMLInputElement | null>(null);
let renameBlurTimer: number | undefined;
function cancelPendingRenameBlur() {
if (renameBlurTimer === undefined) return;
window.clearTimeout(renameBlurTimer);
renameBlurTimer = undefined;
}
function handleRenameInputFocus() {
cancelPendingRenameBlur();
}
function handleRenameInputBlur() {
cancelPendingRenameBlur();
renameBlurTimer = window.setTimeout(() => {
renameBlurTimer = undefined;
void confirmRename();
}, 0);
}
function setRenameInputRef(el: unknown) {
renameInputRef.value = (el as HTMLInputElement) ?? null;
}
@@ -830,18 +859,31 @@ function startRenameFolder(folder: SavedSqlFolder) {
});
}
function startRenameFile(file: SavedSqlFile) {
function startRenameFile(file: SavedSqlFile, options: { restoreFocusAfterEditorMount?: boolean } = {}) {
prepareRenameInput();
setActiveItem(file.id, "file");
renamingTarget.value = { type: "file", id: file.id };
renameValue.value = file.name.replace(/\.sql$/i, "");
nextTick(() => {
focusSidebarRenameInput(() => renameInputRef.value ?? undefined);
if (options.restoreFocusAfterEditorMount) {
// QueryEditor requests focus on its own nextTick + animation frame when
// a new tab mounts. Restore the rename focus one frame later so that
// editor autofocus cannot immediately steal it back.
const restoreFocus = () => {
const input = renameInputRef.value;
input?.focus();
input?.select();
};
if (typeof requestAnimationFrame === "function") requestAnimationFrame(restoreFocus);
else setTimeout(restoreFocus, 0);
}
});
}
async function confirmRename() {
if (!renamingTarget.value) return;
cancelPendingRenameBlur();
const { type, id } = renamingTarget.value;
const name = renameValue.value.trim();
renamingTarget.value = null;
@@ -859,6 +901,7 @@ async function confirmRename() {
}
function cancelRename() {
cancelPendingRenameBlur();
renamingTarget.value = null;
renameValue.value = "";
}
@@ -1011,6 +1054,68 @@ function handleFolderClick(folder: SavedSqlFolder, event: MouseEvent) {
const contextTarget = ref<SavedSqlFolder | SavedSqlFile | "panel" | null>(null);
function pasteTargetForLibraryContext(target: SavedSqlFolder | SavedSqlFile | "panel") {
if (target === "panel") {
const connectionId = connectionStore.activeConnectionId || connectionStore.connections[0]?.id;
if (!connectionId) return null;
const activeTab = queryStore.tabs.find((tab) => tab.id === queryStore.activeTabId);
const config = connectionStore.getConfig(connectionId);
return {
connectionId,
database: activeTab?.connectionId === connectionId && activeTab.database ? activeTab.database : config ? targetDefaultDatabase(config) || config.database || "" : "",
};
}
if ("sql" in target) {
return {
connectionId: target.connectionId,
catalog: target.catalog,
database: target.database || "",
schema: target.schema,
folderId: target.folderId,
};
}
const folderFiles = savedSqlStore.filesInFolder(target.id);
const activeTab = queryStore.tabs.find((tab) => tab.id === queryStore.activeTabId);
const config = connectionStore.getConfig(target.connectionId);
return {
connectionId: target.connectionId,
database: folderFiles.find((file) => file.database)?.database || (activeTab?.connectionId === target.connectionId && activeTab.database ? activeTab.database : undefined) || (config ? targetDefaultDatabase(config) || config.database || "" : ""),
folderId: target.id,
};
}
async function copyLibraryFiles(fileIds: readonly string[]) {
const uniqueIds = [...new Set(fileIds)].filter((id) => !!savedSqlStore.getFile(id));
if (uniqueIds.length === 0) return;
connectionStore.treeClipboard = { kind: "saved-sql-copy", fileIds: uniqueIds };
try {
await copyToClipboard(
uniqueIds
.map((id) => savedSqlStore.getFile(id)?.name)
.filter(Boolean)
.join("\n"),
);
} catch {
// The internal clipboard remains available when the system clipboard is denied.
}
toast(t("savedSql.copied", { count: uniqueIds.length }), 2000);
}
async function pasteLibraryFiles(target: SavedSqlFolder | SavedSqlFile | "panel") {
const clipboard = connectionStore.treeClipboard;
const destination = pasteTargetForLibraryContext(target);
if (clipboard?.kind !== "saved-sql-copy" || clipboard.fileIds.length === 0 || !destination) return;
try {
const files = await savedSqlStore.copyFilesToDatabase(clipboard.fileIds, destination);
if (files.length > 0) toast(t("savedSql.pasted", { count: files.length }), 2000);
else toast(t("savedSql.nothingToPaste"), 3000);
} catch (error) {
toast(t("savedSql.pasteFailed", { message: savedSqlErrorMessage(error, t) }), 5000);
}
}
function isContextFile(fileId: string): boolean {
return contextTarget.value !== null && contextTarget.value !== "panel" && "sql" in contextTarget.value && contextTarget.value.id === fileId;
}
@@ -1083,6 +1188,12 @@ const contextMenuItems = computed<CtxMenuItem[]>(() => {
if (hasSelection.value) {
const selectedFiles = [...new Set([...Array.from(selectedFileIds.value), ...Array.from(selectedFolderIds.value).flatMap((folderId) => allFilesInFolderBranch(folderId).map((f) => f.id))])];
return [
{
label: t("savedSql.copyFile"),
action: () => copyLibraryFiles(selectedFiles),
icon: Copy,
visible: selectedFiles.length > 0,
},
{
label: t("sqlLibrary.changeTarget", { count: selectedFiles.length }),
action: () => openChangeTarget(selectedFiles),
@@ -1114,6 +1225,12 @@ const contextMenuItems = computed<CtxMenuItem[]>(() => {
return [
{ label: t("savedSql.newFolder"), action: openNewFolderInput, icon: FolderPlus },
{ label: t("savedSql.newQuery"), action: () => openNewQueryInFolder(), icon: FilePlus },
{
label: t("savedSql.pasteFile"),
action: () => pasteLibraryFiles("panel"),
icon: Clipboard,
disabled: connectionStore.treeClipboard?.kind !== "saved-sql-copy",
},
{ label: t("sqlLibrary.importDirectory"), action: () => importDirectoryIntoLibrary(), icon: Download },
{ label: t("sqlLibrary.exportLibrary"), action: () => exportFolderContents(), icon: Upload },
...(hasSortOptions
@@ -1173,6 +1290,13 @@ const contextMenuItems = computed<CtxMenuItem[]>(() => {
disabled: !hasCurrentSavedSqlExecutionTarget.value,
},
{ label: t("sqlLibrary.changeTarget", { count: 1 }), action: () => openChangeTarget([target.id]), icon: ArrowRightLeft },
{ label: t("savedSql.copyFile"), action: () => copyLibraryFiles([target.id]), icon: Copy },
{
label: t("savedSql.pasteFile"),
action: () => pasteLibraryFiles(target),
icon: Clipboard,
disabled: connectionStore.treeClipboard?.kind !== "saved-sql-copy",
},
{ label: t("sqlLibrary.exportFile"), action: () => exportSingleFile(target), icon: Upload },
{ label: t("sqlLibrary.moveToFolder"), icon: FolderClosed, children: folderMoveMenuItems([target.id]) },
{ label: "", separator: true },
@@ -1192,6 +1316,12 @@ const contextMenuItems = computed<CtxMenuItem[]>(() => {
return [
{ label: t("savedSql.newSubfolder"), action: () => openNewFolderInput(target.id), icon: FolderPlus },
{ label: t("savedSql.newQuery"), action: () => openNewQueryInFolder(target), icon: FilePlus },
{
label: t("savedSql.pasteFile"),
action: () => pasteLibraryFiles(target),
icon: Clipboard,
disabled: connectionStore.treeClipboard?.kind !== "saved-sql-copy",
},
{
label: t("sqlLibrary.changeTarget", { count: folderFiles.length }),
action: () => openChangeTarget(folderFiles.map((f) => f.id)),
@@ -1396,6 +1526,7 @@ onBeforeUnmount(() => {
document.removeEventListener("mousemove", onDocumentMouseMove, true);
document.removeEventListener("mouseup", onDocumentMouseUp, true);
window.clearTimeout(clearSuppressTimer);
cancelPendingRenameBlur();
resetDragState();
});
@@ -1556,11 +1687,13 @@ function showDropInside(targetId: string) {
<input
:ref="setRenameInputRef"
v-model="renameValue"
data-preserve-editor-focus
data-no-drag="true"
class="min-w-0 flex-1 rounded border border-primary/50 bg-transparent px-1 text-[13px] outline-none"
@keydown.enter.prevent="confirmRename"
@keydown.escape.prevent="cancelRename"
@blur="confirmRename"
@focus="handleRenameInputFocus"
@blur="handleRenameInputBlur"
@mousedown.stop
@click.stop
/>
@@ -1601,11 +1734,13 @@ function showDropInside(targetId: string) {
<input
:ref="setRenameInputRef"
v-model="renameValue"
data-preserve-editor-focus
data-no-drag="true"
class="min-w-0 flex-1 rounded border border-primary/50 bg-transparent px-1 text-[13px] outline-none"
@keydown.enter.prevent="confirmRename"
@keydown.escape.prevent="cancelRename"
@blur="confirmRename"
@focus="handleRenameInputFocus"
@blur="handleRenameInputBlur"
@mousedown.stop
@click.stop
/>
@@ -1643,11 +1778,13 @@ function showDropInside(targetId: string) {
<input
:ref="setRenameInputRef"
v-model="renameValue"
data-preserve-editor-focus
data-no-drag="true"
class="min-w-0 flex-1 rounded border border-primary/50 bg-transparent px-1 text-[13px] outline-none"
@keydown.enter.prevent="confirmRename"
@keydown.escape.prevent="cancelRename"
@blur="confirmRename"
@focus="handleRenameInputFocus"
@blur="handleRenameInputBlur"
@mousedown.stop
@click.stop
/>
@@ -1693,11 +1830,13 @@ function showDropInside(targetId: string) {
<input
:ref="setRenameInputRef"
v-model="renameValue"
data-preserve-editor-focus
data-no-drag="true"
class="min-w-0 flex-1 rounded border border-primary/50 bg-transparent px-1 text-[13px] outline-none"
@keydown.enter.prevent="confirmRename"
@keydown.escape.prevent="cancelRename"
@blur="confirmRename"
@focus="handleRenameInputFocus"
@blur="handleRenameInputBlur"
@mousedown.stop
@click.stop
/>
@@ -1741,11 +1880,13 @@ function showDropInside(targetId: string) {
<input
:ref="setRenameInputRef"
v-model="renameValue"
data-preserve-editor-focus
data-no-drag="true"
class="min-w-0 flex-1 rounded border border-primary/50 bg-transparent px-1 text-[13px] outline-none"
@keydown.enter.prevent="confirmRename"
@keydown.escape.prevent="cancelRename"
@blur="confirmRename"
@focus="handleRenameInputFocus"
@blur="handleRenameInputBlur"
@mousedown.stop
@click.stop
/>
@@ -1,3 +1,6 @@
/**
* @vitest-environment happy-dom
*/
import { describe, expect, it, vi } from "vitest";
import { focusEditorView, type EditorViewLike } from "@/lib/editor/queryEditorFocus";
@@ -31,4 +34,21 @@ describe("focusEditorView", () => {
it("returns false when view is undefined", () => {
expect(focusEditorView(undefined)).toBe(false);
});
it("preserves a surrounding input that temporarily owns focus", () => {
const input = document.createElement("input");
input.dataset.preserveEditorFocus = "";
document.body.appendChild(input);
input.focus();
const view = createMockView();
try {
const result = focusEditorView(view);
expect(result).toBe(false);
expect(document.activeElement).toBe(input);
expect(view.focus).not.toHaveBeenCalled();
} finally {
input.remove();
}
});
});
@@ -10,11 +10,28 @@ export interface EditorViewLike {
focus(): void;
}
const PRESERVED_EDITOR_FOCUS_SELECTOR = "[data-preserve-editor-focus]";
/**
* Focus the editor if it exists and does not already have focus.
* A surrounding surface can temporarily own focus while a query editor is
* mounting (for example, the SQL library's inline rename input). Query editor
* autofocus must respect that handoff instead of immediately stealing focus.
*/
function focusPreservedElement(): boolean {
if (typeof document === "undefined") return false;
const target = document.querySelector<HTMLElement>(PRESERVED_EDITOR_FOCUS_SELECTOR);
if (!target || document.activeElement !== target) return false;
target.focus();
return true;
}
/**
* Focus the editor if it exists and does not already have focus, while
* respecting a surrounding control that currently owns a focus handoff.
* Returns `true` when `view.focus()` was actually called, `false` otherwise.
*/
export function focusEditorView(view: EditorViewLike | null | undefined): boolean {
if (focusPreservedElement()) return false;
if (!view || view.hasFocus) return false;
view.focus();
return true;
+4 -1
View File
@@ -35,6 +35,8 @@ interface SavedSqlExecutionTargetInput {
database: string;
catalog?: string;
schema?: string;
/** Explicit destination folder used by SQL-library paste. */
folderId?: string;
}
interface SavedSqlExecutionTargetUpdateOptions {
@@ -664,7 +666,8 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
const source = await ensureFileContent(fileId);
if (!source) continue;
const sourceFolder = source.folderId ? folders.value.find((folder) => folder.id === source.folderId) : undefined;
const folderId = sourceFolder?.connectionId === normalizedTarget.connectionId ? source.folderId : undefined;
const requestedFolder = normalizedTarget.folderId ? folders.value.find((folder) => folder.id === normalizedTarget.folderId) : undefined;
const folderId = requestedFolder?.connectionId === normalizedTarget.connectionId ? requestedFolder.id : sourceFolder?.connectionId === normalizedTarget.connectionId ? source.folderId : undefined;
const copyScope = { ...normalizedTarget, folderId };
const name = nextSavedSqlCopyName(source.name, takenFileNames(copyScope));
const keepSourceScope = savedSqlDatabaseScopeKey(source) === savedSqlDatabaseScopeKey(normalizedTarget);