feat(sql-library): add move to folder action
This commit is contained in:
parent
69f880fdf1
commit
614daa5b19
|
|
@ -51,6 +51,19 @@ function getConnectionLabel(connectionId: string) {
|
|||
return conn?.name || connectionId;
|
||||
}
|
||||
|
||||
function folderPath(folder: SavedSqlFolder) {
|
||||
const folderById = new Map(savedSqlStore.allFolders.map((item) => [item.id, item]));
|
||||
const parts: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
let current: SavedSqlFolder | undefined = folder;
|
||||
while (current && !seen.has(current.id)) {
|
||||
seen.add(current.id);
|
||||
parts.unshift(current.name);
|
||||
current = current.parentFolderId ? folderById.get(current.parentFolderId) : undefined;
|
||||
}
|
||||
return parts.join(" / ");
|
||||
}
|
||||
|
||||
function activeImportConnectionId() {
|
||||
return connectionStore.activeConnectionId || connectionStore.connections[0]?.id || "";
|
||||
}
|
||||
|
|
@ -586,6 +599,14 @@ async function executeBatchDelete() {
|
|||
toast(t("sqlLibrary.batchDeleteSuccess", { count: fileIds.length + folderIds.length }), 2000);
|
||||
}
|
||||
|
||||
async function moveFilesToFolder(fileIds: string[], folderId?: string) {
|
||||
const movableIds = [...new Set(fileIds)].filter((id) => savedSqlStore.getFile(id));
|
||||
if (movableIds.length === 0) return;
|
||||
await savedSqlStore.moveFilesToFolder(movableIds, folderId);
|
||||
clearSelection();
|
||||
toast(t("sqlLibrary.moveSuccess", { count: movableIds.length }), 2000);
|
||||
}
|
||||
|
||||
async function openFile(file: SavedSqlFile) {
|
||||
if (suppressNextRowClick.value) return;
|
||||
const loadedFile = await savedSqlStore.ensureFileContent(file.id);
|
||||
|
|
@ -717,13 +738,52 @@ function handleFolderClick(folder: SavedSqlFolder, event: MouseEvent) {
|
|||
|
||||
const contextTarget = ref<SavedSqlFolder | SavedSqlFile | "panel" | null>(null);
|
||||
|
||||
function folderMoveMenuItems(fileIds: string[]): CtxMenuItem[] {
|
||||
const files = [...new Set(fileIds)].map((id) => savedSqlStore.getFile(id)).filter((file): file is SavedSqlFile => Boolean(file));
|
||||
const allInUnfiled = files.length > 0 && files.every((file) => !file.folderId);
|
||||
const folderItems = savedSqlStore.allFoldersTreeOrder
|
||||
.filter((folder) => isConnectionVisible(folder.connectionId))
|
||||
.map((folder) => ({
|
||||
label: folderPath(folder),
|
||||
action: () =>
|
||||
moveFilesToFolder(
|
||||
files.map((file) => file.id),
|
||||
folder.id,
|
||||
),
|
||||
disabled: files.every((file) => file.folderId === folder.id),
|
||||
icon: FolderClosed,
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
label: t("sqlLibrary.unfiled"),
|
||||
action: () =>
|
||||
moveFilesToFolder(
|
||||
files.map((file) => file.id),
|
||||
undefined,
|
||||
),
|
||||
disabled: files.length === 0 || allInUnfiled,
|
||||
icon: FolderOpen,
|
||||
},
|
||||
...(folderItems.length > 0 ? [{ label: "", separator: true }, ...folderItems] : []),
|
||||
];
|
||||
}
|
||||
|
||||
const contextMenuItems = computed<CtxMenuItem[]>(() => {
|
||||
const target = contextTarget.value;
|
||||
if (!target) return [];
|
||||
|
||||
// If there's selection, show batch delete option
|
||||
if (hasSelection.value) {
|
||||
const selectedFiles = Array.from(selectedFileIds.value);
|
||||
return [
|
||||
{
|
||||
label: t("sqlLibrary.moveSelectedToFolder", { count: selectedFiles.length }),
|
||||
icon: FolderClosed,
|
||||
children: folderMoveMenuItems(selectedFiles),
|
||||
visible: selectedFiles.length > 0,
|
||||
},
|
||||
{ label: "", separator: true, visible: selectedFiles.length > 0 },
|
||||
{
|
||||
label: t("sqlLibrary.batchDelete", { count: selectedCount.value }),
|
||||
action: confirmBatchDelete,
|
||||
|
|
@ -756,6 +816,7 @@ const contextMenuItems = computed<CtxMenuItem[]>(() => {
|
|||
return [
|
||||
{ label: t("savedSql.open"), action: () => openFile(target), icon: FileText },
|
||||
{ label: t("sqlLibrary.exportFile"), action: () => exportSingleFile(target), icon: FileInput },
|
||||
{ label: t("sqlLibrary.moveToFolder"), icon: FolderClosed, children: folderMoveMenuItems([target.id]) },
|
||||
{ label: "", separator: true },
|
||||
{ label: t("savedSql.renameFile"), action: () => startRenameFile(target), icon: Pencil },
|
||||
{ label: "", separator: true },
|
||||
|
|
|
|||
|
|
@ -52,9 +52,11 @@ const subRef = ref<HTMLElement>();
|
|||
const subX = ref(0);
|
||||
const subY = ref(0);
|
||||
let subCloseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let subAnchorRect: { left: number; right: number; top: number; bottom: number } | null = null;
|
||||
|
||||
function close() {
|
||||
activeSubIndex.value = null;
|
||||
subAnchorRect = null;
|
||||
show.value = false;
|
||||
}
|
||||
|
||||
|
|
@ -147,6 +149,7 @@ function onItemMouseEnter(index: number, event: MouseEvent) {
|
|||
}
|
||||
const trigger = event.currentTarget as HTMLElement;
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
subAnchorRect = { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom };
|
||||
subX.value = rect.right + 4;
|
||||
subY.value = rect.top;
|
||||
activeSubIndex.value = index;
|
||||
|
|
@ -194,11 +197,25 @@ function adjustSubPosition() {
|
|||
const rect = subRef.value.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
if (rect.right > vw) {
|
||||
subX.value = Math.max(0, vw - rect.width - 8);
|
||||
const margin = 8;
|
||||
const gap = 4;
|
||||
if (subAnchorRect) {
|
||||
const rightX = subAnchorRect.right + gap;
|
||||
const leftX = subAnchorRect.left - rect.width - gap;
|
||||
if (rightX + rect.width <= vw - margin) {
|
||||
subX.value = rightX;
|
||||
} else if (leftX >= margin) {
|
||||
subX.value = leftX;
|
||||
} else {
|
||||
subX.value = Math.max(margin, Math.min(rightX, vw - rect.width - margin));
|
||||
}
|
||||
} else if (rect.right > vw - margin) {
|
||||
subX.value = Math.max(margin, vw - rect.width - margin);
|
||||
}
|
||||
if (rect.bottom > vh) {
|
||||
subY.value = Math.max(0, vh - rect.height - 8);
|
||||
if (rect.bottom > vh - margin) {
|
||||
subY.value = Math.max(margin, vh - rect.height - margin);
|
||||
} else if (rect.top < margin) {
|
||||
subY.value = margin;
|
||||
}
|
||||
// When the submenu flips left due to right-edge overflow, it may land
|
||||
// under the mouse cursor. Since the mouse didn't move, mouseenter won't
|
||||
|
|
@ -272,8 +289,8 @@ onBeforeUnmount(() => {
|
|||
<div
|
||||
v-if="show && activeSubIndex !== null && items[activeSubIndex]?.children?.length"
|
||||
ref="subRef"
|
||||
:style="{ position: 'fixed', left: subX + 'px', top: subY + 'px', zIndex: 10000 }"
|
||||
class="bg-popover text-popover-foreground min-w-40 rounded-[6px] p-1 overflow-x-hidden overflow-y-auto ring-1 ring-foreground/10 shadow-lg"
|
||||
:style="{ position: 'fixed', left: subX + 'px', top: subY + 'px', zIndex: 10000, maxHeight: 'min(420px, calc(100vh - 16px))' }"
|
||||
class="bg-popover text-popover-foreground w-56 max-w-[calc(100vw-16px)] rounded-[6px] p-1 overflow-x-hidden overflow-y-auto ring-1 ring-foreground/10 shadow-lg"
|
||||
@mouseenter="onSubMouseEnter"
|
||||
@mouseleave="onSubMouseLeave"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -146,6 +146,9 @@ export default {
|
|||
batchDelete: "Batch Delete",
|
||||
batchDeleteConfirm: "Are you sure you want to delete {count} selected items? This action cannot be undone.",
|
||||
batchDeleteSuccess: "Successfully deleted {count} items",
|
||||
moveToFolder: "Move to Folder...",
|
||||
moveSelectedToFolder: "Move {count} SQL to Folder...",
|
||||
moveSuccess: "Moved {count} SQL file(s)",
|
||||
clearSelection: "Clear Selection",
|
||||
sortByDate: "Sort by Date Modified",
|
||||
sortByFolder: "Sort by Folder Structure",
|
||||
|
|
|
|||
|
|
@ -148,6 +148,9 @@ export default withEnglishFallback({
|
|||
batchDelete: "Eliminación por lotes",
|
||||
batchDeleteConfirm: "¿Está seguro de que desea eliminar {count} elementos seleccionados? Esta acción no se puede deshacer.",
|
||||
batchDeleteSuccess: "Se eliminaron {count} elementos con éxito",
|
||||
moveToFolder: "Mover a carpeta...",
|
||||
moveSelectedToFolder: "Mover {count} SQL a carpeta...",
|
||||
moveSuccess: "Se movieron {count} SQL",
|
||||
clearSelection: "Limpiar selección",
|
||||
sortByDate: "Ordenar por fecha de modificación",
|
||||
sortByFolder: "Ordenar por estructura de carpetas",
|
||||
|
|
|
|||
|
|
@ -147,6 +147,9 @@ export default withEnglishFallback({
|
|||
batchDelete: "Eliminazione in blocco",
|
||||
batchDeleteConfirm: "Sei sicuro di voler eliminare {count} elementi selezionati? Questa azione non può essere annullata.",
|
||||
batchDeleteSuccess: "{count} elementi eliminati con successo",
|
||||
moveToFolder: "Sposta nella cartella...",
|
||||
moveSelectedToFolder: "Sposta {count} SQL nella cartella...",
|
||||
moveSuccess: "{count} SQL spostati",
|
||||
clearSelection: "Cancella selezione",
|
||||
sortByDate: "Ordina per data di modifica",
|
||||
sortByFolder: "Ordina per struttura cartelle",
|
||||
|
|
|
|||
|
|
@ -148,6 +148,9 @@ export default withEnglishFallback({
|
|||
batchDelete: "一括削除",
|
||||
batchDeleteConfirm: "選択した {count} 項目を削除してもよろしいですか?この操作は元に戻せません。",
|
||||
batchDeleteSuccess: "{count} 項目を削除しました",
|
||||
moveToFolder: "フォルダへ移動...",
|
||||
moveSelectedToFolder: "選択した {count} 個の SQL をフォルダへ移動...",
|
||||
moveSuccess: "{count} 個の SQL を移動しました",
|
||||
clearSelection: "選択を解除",
|
||||
sortByDate: "更新日時で並べ替え",
|
||||
sortByFolder: "フォルダ構造で並べ替え",
|
||||
|
|
|
|||
|
|
@ -148,6 +148,9 @@ export default withEnglishFallback({
|
|||
batchDelete: "Exclusão em lote",
|
||||
batchDeleteConfirm: "Tem certeza de que deseja excluir {count} itens selecionados? Esta ação não pode ser desfeita.",
|
||||
batchDeleteSuccess: "{count} itens excluídos com sucesso",
|
||||
moveToFolder: "Mover para pasta...",
|
||||
moveSelectedToFolder: "Mover {count} SQL para pasta...",
|
||||
moveSuccess: "{count} SQL movidos",
|
||||
clearSelection: "Limpar seleção",
|
||||
sortByDate: "Ordenar por data de modificação",
|
||||
sortByFolder: "Ordenar por estrutura de pastas",
|
||||
|
|
|
|||
|
|
@ -148,6 +148,9 @@ export default withEnglishFallback({
|
|||
batchDelete: "批量删除",
|
||||
batchDeleteConfirm: "确定要删除选中的 {count} 项吗?此操作无法撤销。",
|
||||
batchDeleteSuccess: "成功删除 {count} 项",
|
||||
moveToFolder: "分类至...",
|
||||
moveSelectedToFolder: "分类选中的 {count} 个 SQL 至...",
|
||||
moveSuccess: "已分类 {count} 个 SQL",
|
||||
clearSelection: "清除选择",
|
||||
sortByDate: "按修改日期排序",
|
||||
sortByFolder: "按文件夹结构排序",
|
||||
|
|
|
|||
|
|
@ -148,6 +148,9 @@ export default withEnglishFallback({
|
|||
batchDelete: "批次刪除",
|
||||
batchDeleteConfirm: "確定要刪除選取的 {count} 項嗎?此操作無法復原。",
|
||||
batchDeleteSuccess: "成功刪除 {count} 項",
|
||||
moveToFolder: "分類至...",
|
||||
moveSelectedToFolder: "分類選取的 {count} 個 SQL 至...",
|
||||
moveSuccess: "已分類 {count} 個 SQL",
|
||||
clearSelection: "清除選取",
|
||||
sortByDate: "依修改日期排序",
|
||||
sortByFolder: "依資料夾結構排序",
|
||||
|
|
|
|||
|
|
@ -453,6 +453,42 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
|
|||
await persistFiles([...untouched, ...nextSource, ...nextDestination]);
|
||||
}
|
||||
|
||||
async function moveFilesToFolder(fileIds: string[], folderId?: string) {
|
||||
const uniqueIds = [...new Set(fileIds)];
|
||||
if (uniqueIds.length === 0) return;
|
||||
|
||||
const targetFolderId = folderId || undefined;
|
||||
const movingFiles = uniqueIds.map((id) => files.value.find((file) => file.id === id)).filter((file): file is SavedSqlFile => Boolean(file));
|
||||
const filesToMove = movingFiles.filter((file) => (file.folderId || undefined) !== targetFolderId);
|
||||
if (filesToMove.length === 0) return;
|
||||
const moveIdSet = new Set(filesToMove.map((file) => file.id));
|
||||
|
||||
const timestamp = nowIso();
|
||||
const affectedFolderIds = new Set<string>(filesToMove.map((file) => file.folderId || ""));
|
||||
affectedFolderIds.add(targetFolderId || "");
|
||||
|
||||
const movedFiles = filesToMove.map((file) => ({
|
||||
...file,
|
||||
folderId: targetFolderId,
|
||||
updatedAt: timestamp,
|
||||
}));
|
||||
|
||||
// Reindex each touched folder separately so moving a batch out of one
|
||||
// folder never rewrites unrelated siblings into the destination folder.
|
||||
const nextAffectedFiles = Array.from(affectedFolderIds).flatMap((groupId) => {
|
||||
const normalizedGroupId = groupId || undefined;
|
||||
const remaining = sortFilesByOrder(files.value.filter((file) => (file.folderId || "") === groupId && !moveIdSet.has(file.id)));
|
||||
const group = groupId === (targetFolderId || "") ? [...remaining, ...movedFiles] : remaining;
|
||||
return reindexFiles(group, normalizedGroupId).map((file) => ({
|
||||
...file,
|
||||
updatedAt: timestamp,
|
||||
}));
|
||||
});
|
||||
const untouched = files.value.filter((file) => !affectedFolderIds.has(file.folderId || "") && !moveIdSet.has(file.id));
|
||||
|
||||
await persistFiles([...untouched, ...nextAffectedFiles]);
|
||||
}
|
||||
|
||||
async function reorderFiles(draggedId: string, targetId: string, position: "before" | "after") {
|
||||
const dragged = files.value.find((file) => file.id === draggedId);
|
||||
const target = files.value.find((file) => file.id === targetId);
|
||||
|
|
@ -531,6 +567,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
|
|||
moveFolderToFolder,
|
||||
reorderFiles,
|
||||
moveFileToFolder,
|
||||
moveFilesToFolder,
|
||||
syncToLocalDirectory,
|
||||
allFolders,
|
||||
allFoldersTreeOrder,
|
||||
|
|
|
|||
|
|
@ -135,3 +135,95 @@ test("saving an existing SQL file with root folder explicitly moves it to root",
|
|||
assert.equal(apiMock.saveSavedSqlFile.mock.calls[0]?.[0].folderId, undefined);
|
||||
assert.equal(store.getFile("sql-1")?.folderId, undefined);
|
||||
});
|
||||
|
||||
test("moving multiple saved SQL files to a folder keeps existing target files", async () => {
|
||||
const files: SavedSqlFile[] = [
|
||||
{
|
||||
id: "sql-1",
|
||||
connectionId: "conn-1",
|
||||
name: "one.sql",
|
||||
database: "db",
|
||||
sql: "SELECT 1;",
|
||||
orderIndex: 0,
|
||||
createdAt: "2026-06-27T00:00:00.000Z",
|
||||
updatedAt: "2026-06-27T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "sql-2",
|
||||
connectionId: "conn-1",
|
||||
name: "two.sql",
|
||||
database: "db",
|
||||
sql: "SELECT 2;",
|
||||
orderIndex: 1,
|
||||
createdAt: "2026-06-27T00:00:00.000Z",
|
||||
updatedAt: "2026-06-27T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "sql-3",
|
||||
connectionId: "conn-1",
|
||||
folderId: "folder-1",
|
||||
name: "three.sql",
|
||||
database: "db",
|
||||
sql: "SELECT 3;",
|
||||
orderIndex: 0,
|
||||
createdAt: "2026-06-27T00:00:00.000Z",
|
||||
updatedAt: "2026-06-27T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files });
|
||||
|
||||
const store = useSavedSqlStore();
|
||||
await store.initFromStorage();
|
||||
|
||||
await store.moveFilesToFolder(["sql-1", "sql-2"], "folder-1");
|
||||
|
||||
assert.deepEqual(
|
||||
store.filesInFolder("folder-1").map((file) => [file.id, file.folderId, file.orderIndex]),
|
||||
[
|
||||
["sql-3", "folder-1", 0],
|
||||
["sql-1", "folder-1", 1],
|
||||
["sql-2", "folder-1", 2],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
store.filesWithoutFolder().map((file) => file.id),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("moving selected files already in the target folder keeps them in place", async () => {
|
||||
const files: SavedSqlFile[] = [
|
||||
{
|
||||
id: "sql-1",
|
||||
connectionId: "conn-1",
|
||||
name: "one.sql",
|
||||
database: "db",
|
||||
sql: "SELECT 1;",
|
||||
orderIndex: 0,
|
||||
createdAt: "2026-06-27T00:00:00.000Z",
|
||||
updatedAt: "2026-06-27T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "sql-2",
|
||||
connectionId: "conn-1",
|
||||
folderId: "folder-1",
|
||||
name: "two.sql",
|
||||
database: "db",
|
||||
sql: "SELECT 2;",
|
||||
orderIndex: 0,
|
||||
createdAt: "2026-06-27T00:00:00.000Z",
|
||||
updatedAt: "2026-06-27T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files });
|
||||
|
||||
const store = useSavedSqlStore();
|
||||
await store.initFromStorage();
|
||||
|
||||
await store.moveFilesToFolder(["sql-1", "sql-2"], "folder-1");
|
||||
|
||||
assert.deepEqual(
|
||||
store.filesInFolder("folder-1").map((file) => file.id),
|
||||
["sql-2", "sql-1"],
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue