feat(sql-library): add sort by date modified

- Add sort mode toggle button (folder structure / date modified)
- Sort by date: flat list with all folders and files by updatedAt desc
- Most recently modified items appear at top for quick access
- Hide 'Unfiled' label when not dragging (only show as drop target)
- Remove excessive spacing between folder tree and unfiled section
This commit is contained in:
t8y2
2026-06-22 10:57:31 +08:00
parent 629c92bf7e
commit 72300a2b8f
8 changed files with 187 additions and 109 deletions
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, reactive, ref } from "vue";
import { useI18n } from "vue-i18n";
import { Download, FileInput, FilePlus, FileText, FolderCog, FolderClosed, FolderOpen, FolderPlus, Library, LocateFixed, Pencil, Search, Trash2, Upload, X } from "@lucide/vue";
import { Download, FileInput, FilePlus, FileText, FolderCog, FolderClosed, FolderOpen, FolderPlus, Library, LocateFixed, Pencil, Search, Trash2, Upload, X, ArrowDownWideNarrow } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import CustomContextMenu, { type ContextMenuItem as CtxMenuItem } from "@/components/ui/CustomContextMenu.vue";
@@ -37,6 +37,9 @@ const searchText = ref("");
const searchQuery = computed(() => searchText.value.trim().toLowerCase());
const orphanedIds = computed(() => savedSqlStore.orphanedFileIds(activeConnectionIds.value));
// Sort mode: "folder" (default tree structure) or "date" (flat list by update date)
const sortMode = ref<"folder" | "date">("folder");
function isConnectionVisible(connectionId: string) {
return activeConnectionIds.value.has(connectionId);
}
@@ -368,6 +371,18 @@ const visibleFiles = computed(() =>
.filter((file) => fileMatchesQuery(file)),
);
// Flat list sorted by updatedAt (descending) - combines all folders and files
const itemsByDate = computed(() => {
const allFolders = savedSqlStore.allFolders
.filter((folder) => isConnectionVisible(folder.connectionId))
.filter((folder) => folderBranchMatchesQuery(folder))
.map((folder) => ({ type: "folder" as const, item: folder, updatedAt: folder.updatedAt }));
const allFiles = [...savedSqlStore.allFolders.flatMap((folder) => filesInFolder(folder.id)), ...visibleFiles.value].map((file) => ({ type: "file" as const, item: file, updatedAt: file.updatedAt }));
return [...allFolders, ...allFiles].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
});
const hasAnyVisibleItem = computed(() => visibleFolderRows.value.length > 0 || visibleFiles.value.length > 0);
const collapsedFolders = ref<Set<string>>(new Set());
@@ -983,6 +998,9 @@ function showDropInside(targetId: string) {
<span class="text-[13px] font-medium">{{ t("sqlLibrary.title") }}</span>
<span v-if="hasSelection" class="text-[12px] text-muted-foreground ml-1">({{ selectedCount }})</span>
<span class="flex-1" />
<Button variant="ghost" size="icon" class="h-5 w-5" :title="sortMode === 'folder' ? t('sqlLibrary.sortByDate') : t('sqlLibrary.sortByFolder')" @click="sortMode = sortMode === 'folder' ? 'date' : 'folder'">
<ArrowDownWideNarrow :class="['h-3 w-3', sortMode === 'date' ? 'text-primary' : '']" />
</Button>
<Button variant="ghost" size="icon" class="h-5 w-5" :title="t('savedSql.newFolder')" @click="openNewFolderInput">
<FolderPlus class="h-3 w-3" />
</Button>
@@ -1018,121 +1036,167 @@ function showDropInside(targetId: string) {
onContextMenu($event);
"
>
<div v-for="row in visibleFolderRows" :key="row.type === 'folder' ? row.folder.id : row.file.id">
<div
v-if="row.type === 'folder'"
class="relative flex items-center gap-1 rounded py-1.5 pr-2 text-[13px] cursor-pointer group"
:style="{ paddingLeft: `${8 + row.depth * 16}px` }"
:class="[showDropInside(row.folder.id) ? 'ring-1 ring-primary/50 bg-primary/5' : isFolderSelected(row.folder.id) ? 'bg-primary/10' : isFolderActive(row.folder.id) ? 'bg-accent' : 'hover:bg-accent', isDraggingItem(row.folder.id) ? 'opacity-50' : '']"
@mousedown="handleDragMouseDown($event, row.folder.id, 'folder')"
@mousemove="updateDropTarget($event, row.folder.id, 'folder')"
@mouseleave="clearDropTarget(row.folder.id)"
@click="handleFolderClick(row.folder, $event)"
@contextmenu.capture="contextTarget = row.folder"
@contextmenu.prevent="
contextTarget = row.folder;
onContextMenu($event);
"
>
<div v-if="showDropBefore(row.folder.id)" class="absolute left-2 right-2 top-0 border-t-2 border-primary" />
<div v-if="showDropAfter(row.folder.id)" class="absolute left-2 right-2 bottom-0 border-b-2 border-primary" />
<component :is="isFolderExpanded(row.folder.id) ? FolderOpen : FolderClosed" class="h-4 w-4 text-amber-500 shrink-0" />
<template v-if="renamingTarget?.type === 'folder' && renamingTarget.id === row.folder.id">
<input
:ref="setRenameInputRef"
v-model="renameValue"
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"
@click.stop
/>
</template>
<span v-else class="dbx-sql-library-drag-label min-w-0 flex-1 truncate">
{{ row.folder.name }}
<span class="ml-1 text-muted-foreground">({{ filesInFolder(row.folder.id).length }})</span>
</span>
</div>
<!-- Flat list sorted by date -->
<div v-if="sortMode === 'date'" class="space-y-0">
<div v-for="item in itemsByDate" :key="item.type + '-' + item.item.id">
<div
v-if="item.type === 'folder'"
class="relative flex items-center gap-1 rounded px-2 py-1.5 text-[13px] cursor-pointer group"
:class="[isFolderSelected(item.item.id) ? 'bg-primary/10' : isFolderActive(item.item.id) ? 'bg-accent' : 'hover:bg-accent', isDraggingItem(item.item.id) ? 'opacity-50' : '']"
@mousedown="handleDragMouseDown($event, item.item.id, 'folder')"
@click="handleFolderClick(item.item, $event)"
@contextmenu.capture="contextTarget = item.item"
@contextmenu.prevent="
contextTarget = item.item;
onContextMenu($event);
"
>
<FolderClosed class="h-4 w-4 text-amber-500 shrink-0" />
<span class="dbx-sql-library-drag-label min-w-0 flex-1 truncate">
{{ item.item.name }}
<span class="ml-1 text-muted-foreground">({{ filesInFolder(item.item.id).length }})</span>
</span>
</div>
<div
v-else
class="relative flex items-center gap-1 rounded py-1.5 pr-2 text-[13px] cursor-pointer group"
:style="{ paddingLeft: `${8 + row.depth * 16}px` }"
:class="[isDraggingItem(row.file.id) ? 'opacity-50' : isFileSelected(row.file.id) ? 'bg-primary/10' : isFileActive(row.file.id) ? 'bg-accent' : 'hover:bg-accent']"
@mousedown="handleDragMouseDown($event, row.file.id, 'file')"
@mousemove="updateDropTarget($event, row.file.id, 'file')"
@mouseleave="clearDropTarget(row.file.id)"
@click="handleFileClick(row.file, $event)"
@contextmenu.capture="contextTarget = row.file"
@contextmenu.prevent="
contextTarget = row.file;
onContextMenu($event);
"
>
<div v-if="showDropBefore(row.file.id)" class="absolute left-2 right-2 top-0 border-t-2 border-primary" />
<div v-if="showDropAfter(row.file.id)" class="absolute left-2 right-2 bottom-0 border-b-2 border-primary" />
<FileText class="h-3.5 w-3.5 text-blue-400 shrink-0" />
<template v-if="renamingTarget?.type === 'file' && renamingTarget.id === row.file.id">
<input
:ref="setRenameInputRef"
v-model="renameValue"
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"
@click.stop
/>
</template>
<span v-else class="dbx-sql-library-drag-label min-w-0 flex-1 truncate">{{ row.file.name }}</span>
<span class="shrink-0 text-[13px] text-muted-foreground"> [{{ getConnectionLabel(row.file.connectionId) }}] </span>
<div
v-else
class="relative flex items-center gap-1 rounded px-2 py-1.5 text-[13px] cursor-pointer group"
:class="[isDraggingItem(item.item.id) ? 'opacity-50' : isFileSelected(item.item.id) ? 'bg-primary/10' : isFileActive(item.item.id) ? 'bg-accent' : 'hover:bg-accent']"
@mousedown="handleDragMouseDown($event, item.item.id, 'file')"
@click="handleFileClick(item.item, $event)"
@contextmenu.capture="contextTarget = item.item"
@contextmenu.prevent="
contextTarget = item.item;
onContextMenu($event);
"
>
<FileText class="h-3.5 w-3.5 text-blue-400 shrink-0" />
<span class="dbx-sql-library-drag-label min-w-0 flex-1 truncate">{{ item.item.name }}</span>
<span class="shrink-0 text-[13px] text-muted-foreground"> [{{ getConnectionLabel(item.item.connectionId) }}] </span>
</div>
</div>
</div>
<div v-if="visibleFiles.length > 0 || dragState.draggedType === 'file'" class="mt-2">
<div
class="relative rounded px-2 py-1 text-[10px] font-medium uppercase text-muted-foreground"
:class="showDropInside(UNFILED_DROP_TARGET_ID) ? 'ring-1 ring-primary/50 bg-primary/5' : ''"
@mousemove="updateDropTarget($event, UNFILED_DROP_TARGET_ID, 'unfiled')"
@mouseleave="clearDropTarget(UNFILED_DROP_TARGET_ID)"
>
{{ t("sqlLibrary.unfiled") }}
<!-- Tree structure sorted by folder -->
<div v-else>
<div v-for="row in visibleFolderRows" :key="row.type === 'folder' ? row.folder.id : row.file.id">
<div
v-if="row.type === 'folder'"
class="relative flex items-center gap-1 rounded py-1.5 pr-2 text-[13px] cursor-pointer group"
:style="{ paddingLeft: `${8 + row.depth * 16}px` }"
:class="[showDropInside(row.folder.id) ? 'ring-1 ring-primary/50 bg-primary/5' : isFolderSelected(row.folder.id) ? 'bg-primary/10' : isFolderActive(row.folder.id) ? 'bg-accent' : 'hover:bg-accent', isDraggingItem(row.folder.id) ? 'opacity-50' : '']"
@mousedown="handleDragMouseDown($event, row.folder.id, 'folder')"
@mousemove="updateDropTarget($event, row.folder.id, 'folder')"
@mouseleave="clearDropTarget(row.folder.id)"
@click="handleFolderClick(row.folder, $event)"
@contextmenu.capture="contextTarget = row.folder"
@contextmenu.prevent="
contextTarget = row.folder;
onContextMenu($event);
"
>
<div v-if="showDropBefore(row.folder.id)" class="absolute left-2 right-2 top-0 border-t-2 border-primary" />
<div v-if="showDropAfter(row.folder.id)" class="absolute left-2 right-2 bottom-0 border-b-2 border-primary" />
<component :is="isFolderExpanded(row.folder.id) ? FolderOpen : FolderClosed" class="h-4 w-4 text-amber-500 shrink-0" />
<template v-if="renamingTarget?.type === 'folder' && renamingTarget.id === row.folder.id">
<input
:ref="setRenameInputRef"
v-model="renameValue"
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"
@click.stop
/>
</template>
<span v-else class="dbx-sql-library-drag-label min-w-0 flex-1 truncate">
{{ row.folder.name }}
<span class="ml-1 text-muted-foreground">({{ filesInFolder(row.folder.id).length }})</span>
</span>
</div>
<div
v-else
class="relative flex items-center gap-1 rounded py-1.5 pr-2 text-[13px] cursor-pointer group"
:style="{ paddingLeft: `${8 + row.depth * 16}px` }"
:class="[isDraggingItem(row.file.id) ? 'opacity-50' : isFileSelected(row.file.id) ? 'bg-primary/10' : isFileActive(row.file.id) ? 'bg-accent' : 'hover:bg-accent']"
@mousedown="handleDragMouseDown($event, row.file.id, 'file')"
@mousemove="updateDropTarget($event, row.file.id, 'file')"
@mouseleave="clearDropTarget(row.file.id)"
@click="handleFileClick(row.file, $event)"
@contextmenu.capture="contextTarget = row.file"
@contextmenu.prevent="
contextTarget = row.file;
onContextMenu($event);
"
>
<div v-if="showDropBefore(row.file.id)" class="absolute left-2 right-2 top-0 border-t-2 border-primary" />
<div v-if="showDropAfter(row.file.id)" class="absolute left-2 right-2 bottom-0 border-b-2 border-primary" />
<FileText class="h-3.5 w-3.5 text-blue-400 shrink-0" />
<template v-if="renamingTarget?.type === 'file' && renamingTarget.id === row.file.id">
<input
:ref="setRenameInputRef"
v-model="renameValue"
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"
@click.stop
/>
</template>
<span v-else class="dbx-sql-library-drag-label min-w-0 flex-1 truncate">{{ row.file.name }}</span>
<span class="shrink-0 text-[13px] text-muted-foreground"> [{{ getConnectionLabel(row.file.connectionId) }}] </span>
</div>
</div>
<div
v-for="file in visibleFiles"
:key="file.id"
class="relative flex items-center gap-1 rounded px-2 py-1.5 text-[13px] cursor-pointer group"
:class="[isDraggingItem(file.id) ? 'opacity-50' : isFileSelected(file.id) ? 'bg-primary/10' : 'hover:bg-accent']"
@mousedown="handleDragMouseDown($event, file.id, 'file')"
@mousemove="updateDropTarget($event, file.id, 'file')"
@mouseleave="clearDropTarget(file.id)"
@click="handleFileClick(file, $event)"
@contextmenu.capture="contextTarget = file"
@contextmenu.prevent="
contextTarget = file;
onContextMenu($event);
"
>
<div v-if="showDropBefore(file.id)" class="absolute left-2 right-2 top-0 border-t-2 border-primary" />
<div v-if="showDropAfter(file.id)" class="absolute left-2 right-2 bottom-0 border-b-2 border-primary" />
<FileText class="h-3.5 w-3.5 text-blue-400 shrink-0" />
<template v-if="renamingTarget?.type === 'file' && renamingTarget.id === file.id">
<input
:ref="setRenameInputRef"
v-model="renameValue"
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"
@click.stop
/>
</template>
<span v-else class="dbx-sql-library-drag-label min-w-0 flex-1 truncate">{{ file.name }}</span>
<span class="shrink-0 text-[13px] text-muted-foreground"> [{{ getConnectionLabel(file.connectionId) }}] </span>
<div v-if="visibleFiles.length > 0 || dragState.draggedType === 'file'">
<div
v-if="dragState.draggedType === 'file'"
class="relative rounded px-2 py-1 text-[10px] font-medium uppercase text-muted-foreground"
:class="showDropInside(UNFILED_DROP_TARGET_ID) ? 'ring-1 ring-primary/50 bg-primary/5' : ''"
@mousemove="updateDropTarget($event, UNFILED_DROP_TARGET_ID, 'unfiled')"
@mouseleave="clearDropTarget(UNFILED_DROP_TARGET_ID)"
>
{{ t("sqlLibrary.unfiled") }}
</div>
<div
v-for="file in visibleFiles"
:key="file.id"
class="relative flex items-center gap-1 rounded px-2 py-1.5 text-[13px] cursor-pointer group"
:class="[isDraggingItem(file.id) ? 'opacity-50' : isFileSelected(file.id) ? 'bg-primary/10' : 'hover:bg-accent']"
@mousedown="handleDragMouseDown($event, file.id, 'file')"
@mousemove="updateDropTarget($event, file.id, 'file')"
@mouseleave="clearDropTarget(file.id)"
@click="handleFileClick(file, $event)"
@contextmenu.capture="contextTarget = file"
@contextmenu.prevent="
contextTarget = file;
onContextMenu($event);
"
>
<div v-if="showDropBefore(file.id)" class="absolute left-2 right-2 top-0 border-t-2 border-primary" />
<div v-if="showDropAfter(file.id)" class="absolute left-2 right-2 bottom-0 border-b-2 border-primary" />
<FileText class="h-3.5 w-3.5 text-blue-400 shrink-0" />
<template v-if="renamingTarget?.type === 'file' && renamingTarget.id === file.id">
<input
:ref="setRenameInputRef"
v-model="renameValue"
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"
@click.stop
/>
</template>
<span v-else class="dbx-sql-library-drag-label min-w-0 flex-1 truncate">{{ file.name }}</span>
<span class="shrink-0 text-[13px] text-muted-foreground"> [{{ getConnectionLabel(file.connectionId) }}] </span>
</div>
</div>
</div>
<!-- End tree structure -->
<div v-if="!hasAnyVisibleItem" class="flex h-full flex-col items-center justify-center gap-2 text-muted-foreground">
<Library class="h-8 w-8 opacity-30" />
+2
View File
@@ -138,6 +138,8 @@ export default {
batchDeleteConfirm: "Are you sure you want to delete {count} selected items? This action cannot be undone.",
batchDeleteSuccess: "Successfully deleted {count} items",
clearSelection: "Clear Selection",
sortByDate: "Sort by Date Modified",
sortByFolder: "Sort by Folder Structure",
},
connection: {
+2
View File
@@ -136,6 +136,8 @@ export default {
batchDeleteConfirm: "¿Está seguro de que desea eliminar {count} elementos seleccionados? Esta acción no se puede deshacer.",
batchDeleteSuccess: "Se eliminaron {count} elementos con éxito",
clearSelection: "Limpiar selección",
sortByDate: "Ordenar por fecha de modificación",
sortByFolder: "Ordenar por estructura de carpetas",
},
connection: {
+2
View File
@@ -136,6 +136,8 @@ export default {
batchDeleteConfirm: "Sei sicuro di voler eliminare {count} elementi selezionati? Questa azione non può essere annullata.",
batchDeleteSuccess: "{count} elementi eliminati con successo",
clearSelection: "Cancella selezione",
sortByDate: "Ordina per data di modifica",
sortByFolder: "Ordina per struttura cartelle",
},
connection: {
+2
View File
@@ -138,6 +138,8 @@ export default {
batchDeleteConfirm: "選択した {count} 項目を削除してもよろしいですか?この操作は元に戻せません。",
batchDeleteSuccess: "{count} 項目を削除しました",
clearSelection: "選択を解除",
sortByDate: "更新日時で並べ替え",
sortByFolder: "フォルダ構造で並べ替え",
},
connection: {
title: "新しい接続",
+2
View File
@@ -136,6 +136,8 @@ export default {
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",
clearSelection: "Limpar seleção",
sortByDate: "Ordenar por data de modificação",
sortByFolder: "Ordenar por estrutura de pastas",
},
connection: {
+2
View File
@@ -139,6 +139,8 @@ export default {
batchDeleteConfirm: "确定要删除选中的 {count} 项吗?此操作无法撤销。",
batchDeleteSuccess: "成功删除 {count} 项",
clearSelection: "清除选择",
sortByDate: "按修改日期排序",
sortByFolder: "按文件夹结构排序",
},
connection: {
+2
View File
@@ -136,6 +136,8 @@ export default {
batchDeleteConfirm: "確定要刪除選取的 {count} 項嗎?此操作無法復原。",
batchDeleteSuccess: "成功刪除 {count} 項",
clearSelection: "清除選取",
sortByDate: "依修改日期排序",
sortByFolder: "依資料夾結構排序",
},
connection: {