feat(editor): retain multiple query result runs

This commit is contained in:
zhangyc authored and GitHub committed 2026-07-27 15:06:28 +08:00
1 parent fddc0fdf50
commit 7f20b03177
26 files changed
+846 -109

No files matched your search

@@ -134,6 +134,7 @@ const emit = defineEmits<{
cursorChange: [pos: number];
formatError: [message: string];
execute: [source: SqlExecutionOverride];
executeInNewResultTab: [source: SqlExecutionOverride];
save: [];
clickTable: [target: SqlObjectNavigationTarget];
viewTableData: [target: SqlObjectNavigationTarget];
@@ -562,6 +563,15 @@ function performNormalTab(view: EditorViewType): boolean {
interface RequestExecuteOptions {
ignoreSelection?: boolean;
bypassPicker?: boolean;
openInNewResultTab?: boolean;
}
function emitExecutionRequest(source: SqlExecutionOverride, openInNewResultTab = false) {
if (openInNewResultTab) {
emit("executeInNewResultTab", source);
} else {
emit("execute", source);
}
}
function requestExecute(options: RequestExecuteOptions = {}) {
@@ -570,15 +580,19 @@ function requestExecute(options: RequestExecuteOptions = {}) {
return requestExecuteFromView(currentView, currentView.state.selection.main.head, options);
}
function requestExecuteInNewResultTab() {
return requestExecute({ bypassPicker: true, openInNewResultTab: true });
}
function requestExecuteFromView(currentView: EditorViewType, cursorPos: number, options: RequestExecuteOptions = {}) {
const selection = currentView.state.selection.main;
if (!options.ignoreSelection && !selection.empty) {
// Has manual selection → execute directly, skip picker.
emit("execute", sqlExecutionSnapshotFromView(currentView));
emitExecutionRequest(sqlExecutionSnapshotFromView(currentView), options.openInNewResultTab);
return true;
}
if (!supportsExecutionTargetPicker(props.databaseType)) {
emit("execute", sqlExecutionSnapshotFromView(currentView));
emitExecutionRequest(sqlExecutionSnapshotFromView(currentView), options.openInNewResultTab);
return true;
}
// No selection → resolve the execution target, optionally via the picker.
@@ -590,7 +604,7 @@ function requestExecuteFromView(currentView: EditorViewType, cursorPos: number,
if (options.bypassPicker || !settingsStore.editorSettings.showExecutionTargetPicker || !hasMultipleExecutionTargets(doc, props.databaseType)) {
const preferredKind = settingsStore.editorSettings.executeMode === "current" ? "cursor" : "all";
const candidate = candidates.find((item) => item.kind === preferredKind) ?? candidates[0];
emit("execute", candidate.sql);
emitExecutionRequest(candidate.sql, options.openInNewResultTab);
return true;
}
closePicker();
@@ -963,6 +977,12 @@ function executeFromContextMenu() {
focusEditor();
}
function executeInNewResultTabFromContextMenu() {
if (!canExecuteContextSql.value) return;
requestExecuteInNewResultTab();
focusEditor();
}
async function copySelectedSqlFromContextMenu() {
if (!canCopySelectedSql.value) return;
try {
@@ -1230,6 +1250,13 @@ const contextMenuItems = computed<ContextMenuItem[]>(() => {
icon: Play,
shortcut: shortcuts.executeSql,
},
{
label: t("settings.shortcutExecuteSqlInNewResultTab"),
action: executeInNewResultTabFromContextMenu,
disabled: !canExecuteContextSql.value,
icon: Play,
shortcut: shortcuts.executeSqlInNewResultTab,
},
]),
...queryContextObjectActions(contextObjectTarget.value?.type).map(contextObjectMenuItem),
{ label: "", separator: true },
@@ -1299,6 +1326,7 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view")
// Keep the shortcut on the shared execution-mode path (selection priority + configured cursor/all target),
// but bypass the picker so the keyboard shortcut always executes directly instead of popping a dialog.
const executeBindings = props.hideExecutionControls ? [] : binding(shortcuts.executeSql, () => requestExecute({ bypassPicker: true }));
const executeInNewResultTabBindings = props.hideExecutionControls ? [] : binding(shortcuts.executeSqlInNewResultTab, requestExecuteInNewResultTab);
return [
Prec?.high(
codeMirrorKeymap.of([
@@ -1309,6 +1337,7 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view")
},
...binding(shortcuts.find, openSearch),
...binding(shortcuts.replace, openReplace),
...executeInNewResultTabBindings,
...executeBindings,
...binding(shortcuts.saveSql, () => {
emit("save");
@@ -4358,6 +4387,7 @@ defineExpose({
openReplace,
scrollCursorIntoView,
requestExecute,
requestExecuteInNewResultTab,
pasteClipboardAsSqlInCondition,
previewStatementRange,
refreshCompletionCache,
@@ -5,7 +5,7 @@ import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog";
import { canReloadUnavailableDataTab } from "@/lib/table/tableDataRefresh";
import type { CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
import { Check, Columns3, Columns3Cog, EyeOff, Loader2, Search, TableProperties, ChevronDown, ChevronUp, Inbox, RefreshCcw, Wrench, Toolbox, Database, Download, Upload, X, Pin, Rows3, SquareDashed, Minus, Plus, ShieldAlert } from "@lucide/vue";
import { Check, Columns3, Columns3Cog, EyeOff, Loader2, Search, TableProperties, ChevronDown, ChevronUp, Inbox, RefreshCcw, Wrench, Toolbox, Database, Download, Upload, X, Pin, Rows3, SquareDashed, Minus, Plus, ShieldAlert, PanelsTopLeft } from "@lucide/vue";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
import { Button } from "@/components/ui/button";
@@ -65,7 +65,7 @@ const ExplainPlanViewer = defineAsyncComponent(() => import("@/components/explai
const QueryChart = defineAsyncComponent(() => import("@/components/chart/QueryChart.vue"));
import { useQueryStore } from "@/stores/queryStore";
import { useConnectionStore } from "@/stores/connectionStore";
import { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore, type DataGridSearchMode } from "@/stores/settingsStore";
import { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore, type DataGridSearchMode, type ResultRunDisplayMode } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import { canCancelQueryExecution, queryExecutionLabelKey } from "@/lib/sql/queryExecutionState";
import { databaseDisplayNameForTab, executionSummaryItems, queryResultExecutionSql, resultGridCacheKey, resultRunItems, resultSourceRange, resultSqlForGrid, statementExecutionMarkers, tabularResultItems } from "@/lib/tabs/tabPresentation";
@@ -143,6 +143,7 @@ const emit = defineEmits<{
fixWithAi: [errorMessage: string];
sendSelectionToAi: [sql: string];
execute: [sqlOverride?: SqlExecutionOverride];
executeInNewResultTab: [sqlOverride?: SqlExecutionOverride];
saveSql: [];
cancel: [];
explain: [];
@@ -210,6 +211,7 @@ const columnVisibilitySearch = ref("");
const columnVisibilityOptions = computed(() => dataGridRef.value?.filteredColumnVisibilityOptions(columnVisibilitySearch.value) ?? []);
const dataGridRenderMode = computed(() => settingsStore.editorSettings.dataGridRenderMode);
const dataGridSearchMode = computed(() => settingsStore.editorSettings.dataGridSearchMode);
const resultRunDisplayMode = computed(() => settingsStore.editorSettings.resultRunDisplayMode);
const columnWidthDensity = computed(() => settingsStore.editorSettings.columnWidthDensity);
const tableFontSize = computed(() => settingsStore.editorSettings.tableFontSize);
const redisKeyBrowserRef = ref<SearchableBrowserHandle>();
@@ -250,6 +252,10 @@ function setDataGridSearchMode(value: DataGridSearchMode) {
settingsStore.updateEditorSettings({ dataGridSearchMode: value });
}
function setResultRunDisplayMode(value: ResultRunDisplayMode) {
settingsStore.updateEditorSettings({ resultRunDisplayMode: value });
}
function setColumnWidthDensity(value: "compact" | "standard" | "comfortable") {
settingsStore.updateEditorSettings({ columnWidthDensity: value });
}
@@ -323,7 +329,15 @@ const activeQueryError = computed(() => {
});
const hasQueryOutput = computed(
() =>
!!props.activeTab.result || props.activeTab.resultEvicted === true || !!props.activeTab.explainPlan || !!props.activeTab.explainError || !!props.activeTab.explainTableResult || !!props.activeTab.explainTableError || props.activeTab.isExecuting === true || props.activeTab.isExplaining === true,
!!props.activeTab.result ||
!!props.activeTab.resultRuns?.length ||
props.activeTab.resultEvicted === true ||
!!props.activeTab.explainPlan ||
!!props.activeTab.explainError ||
!!props.activeTab.explainTableResult ||
!!props.activeTab.explainTableError ||
props.activeTab.isExecuting === true ||
props.activeTab.isExplaining === true,
);
const visibleResultItems = computed(() => tabularResultItems(props.activeTab.results ?? (props.activeTab.result ? [props.activeTab.result] : undefined)));
const tabularResults = computed(() => tabularResultItems(props.activeTab.results));
@@ -335,7 +349,6 @@ const allResultExportSheets = computed(() =>
})),
);
const resultRuns = computed(() => resultRunItems(props.activeTab));
const activeResultRunItem = computed(() => resultRuns.value.find((run) => run.active));
const activeResultGridCacheKey = computed(() => resultGridCacheKey(props.activeTab));
const activeResultSql = computed(() => resultSqlForGrid(props.activeTab));
const activeResultExportSql = computed(() => queryResultExecutionSql(props.activeTab));
@@ -352,11 +365,16 @@ const activeElasticsearchJsonResponse = computed(() => elasticsearchJsonResponse
const resultArchiveExporting = ref(false);
const canExportResultArchive = computed(() => props.activeTab.mode === "query" && (!!props.activeTab.result || !!props.activeTab.results?.length || !!props.activeTab.resultRuns?.length));
const resultAutoSave = computed(() => props.activeTab.resultAutoSave === true);
const showResultRunSelector = computed(() => resultAutoSave.value && resultRuns.value.length > 0);
const activeResultRunItem = computed(() => resultRuns.value.find((run) => run.active));
const showResultRunTabs = computed(() => resultRuns.value.length > 0 && resultRunDisplayMode.value === "tabs");
const showResultRunSelector = computed(() => resultRuns.value.length > 0 && resultRunDisplayMode.value === "list");
watch(
() => visibleResultItems.value.map((item) => item.index).join(","),
() => `${resultRunDisplayMode.value}:${resultRuns.value.map((run) => run.id).join(",")}:${props.activeTab.activeResultRunId ?? ""}`,
() => {
nextTick(updateResultTabsScrollbar);
nextTick(() => {
updateResultTabsScrollbar();
resultTabsScrollerRef.value?.querySelector<HTMLElement>('[data-active-result-run="true"]')?.scrollIntoView({ block: "nearest", inline: "nearest" });
});
},
);
const summaryItems = computed(() => executionSummaryItems(props.activeTab));
@@ -758,15 +776,44 @@ async function exportResultArchive() {
async function removeResultRun(runId: string) {
const removedActiveRun = props.activeTab.activeResultRunId === runId;
const removed = await queryStore.removeResultRun(props.activeTab.id, runId);
if (removed && removedActiveRun) emit("update:activeOutputView", "result");
if (!removed) return;
if (removedActiveRun) emit("update:activeOutputView", "result");
await nextTick();
const activeRunTab = resultTabsScrollerRef.value?.querySelector<HTMLElement>('[data-active-result-run="true"]');
activeRunTab?.focus({ preventScroll: true });
activeRunTab?.scrollIntoView({ block: "nearest", inline: "nearest" });
}
async function selectResultRun(runId: string) {
if (!(await queryStore.setActiveResultRun(props.activeTab.id, runId))) {
toast(t("tabs.missingResultRun"), 4000);
return;
return false;
}
emit("update:activeOutputView", "result");
return true;
}
async function focusResultRunByIndex(index: number) {
const run = resultRuns.value[index];
if (!run) return;
if (!(await selectResultRun(run.id))) return;
await nextTick();
const runTabs = resultTabsScrollerRef.value?.querySelectorAll<HTMLElement>("[data-result-run-tab]");
const runTab = runTabs?.[index];
runTab?.focus({ preventScroll: true });
runTab?.scrollIntoView({ block: "nearest", inline: "nearest" });
}
function onResultRunTabKeydown(event: KeyboardEvent, currentIndex: number) {
const lastIndex = resultRuns.value.length - 1;
let targetIndex: number | undefined;
if (event.key === "ArrowLeft") targetIndex = currentIndex > 0 ? currentIndex - 1 : lastIndex;
if (event.key === "ArrowRight") targetIndex = currentIndex < lastIndex ? currentIndex + 1 : 0;
if (event.key === "Home") targetIndex = 0;
if (event.key === "End") targetIndex = lastIndex;
if (targetIndex === undefined || targetIndex < 0) return;
event.preventDefault();
void focusResultRunByIndex(targetIndex);
}
function toggleResultAutoSave() {
@@ -794,6 +841,10 @@ function requestQueryEditorExecute() {
return queryEditorRef.value?.requestExecute();
}
function requestQueryEditorExecuteInNewResultTab() {
return queryEditorRef.value?.requestExecuteInNewResultTab();
}
function pasteClipboardAsSqlInCondition() {
return queryEditorRef.value?.pasteClipboardAsSqlInCondition();
}
@@ -812,7 +863,7 @@ async function executeRedisCommand(command: string): Promise<boolean> {
return (await redisKeyBrowserRef.value?.executeCommand?.(command)) ?? false;
}
defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, handleModRTarget, requestQueryEditorExecute, pasteClipboardAsSqlInCondition, applyTableStructureChanges, insertRedisCommand, executeRedisCommand });
defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, handleModRTarget, requestQueryEditorExecute, requestQueryEditorExecuteInNewResultTab, pasteClipboardAsSqlInCondition, applyTableStructureChanges, insertRedisCommand, executeRedisCommand });
</script>
<template>
@@ -860,6 +911,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
@selection-state-change="emit('editorSelectionStateChange', activeTab.id, $event)"
@format-error="emit('formatError')"
@execute="emit('execute', $event)"
@execute-in-new-result-tab="emit('executeInNewResultTab', $event)"
@save="emit('saveSql')"
@click-table="onHandleClickTable"
@view-table-data="onHandleViewTableData"
@@ -892,47 +944,82 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
>
<Pin class="h-3.5 w-3.5" :class="{ 'fill-current': resultAutoSave }" />
</Button>
<template v-if="showResultRunSelector">
<template v-if="resultRuns.length > 0 || visibleResultItems.length > 0">
<span class="mx-1 h-4 w-px shrink-0 bg-border" />
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="sm" class="h-6 shrink-0 gap-1 px-2 text-xs">
{{ activeResultRunItem ? t("tabs.runN", { n: activeResultRunItem.sequence }) : t("tabs.resultRuns") }}
<ChevronDown class="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" class="w-48">
<DropdownMenuItem v-for="run in resultRuns" :key="run.id" class="flex items-center gap-2 pr-1" @select="selectResultRun(run.id)">
<Check v-if="run.active" class="h-3.5 w-3.5 shrink-0" />
<span v-else class="h-3.5 w-3.5 shrink-0" />
<span class="min-w-0 flex-1 truncate">{{ t("tabs.runN", { n: run.sequence }) }}</span>
<button
type="button"
class="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
:title="t('tabs.removeRun', { n: run.sequence })"
:aria-label="t('tabs.removeRun', { n: run.sequence })"
@click.stop.prevent="removeResultRun(run.id)"
>
<X class="h-3 w-3" />
</button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
<template v-if="visibleResultItems.length > 0">
<span class="mx-1 h-4 w-px shrink-0 bg-border" />
<div class="relative min-w-0 flex-1 self-stretch">
<div v-if="showResultRunTabs" data-result-run-tabs-region class="relative min-w-0 flex-1 self-stretch">
<div v-if="hasResultTabOverflow" class="result-tab-scrollbar" :class="{ 'result-tab-scrollbar--dragging': isResultTabsScrollbarDragging }" @pointerdown="startResultTabsScrollbarDrag">
<div class="result-tab-scrollbar__thumb" :style="resultTabsScrollbarThumbStyle" />
</div>
<div ref="resultTabsScrollerRef" class="result-tab-scroll flex h-full items-center gap-1 overflow-x-auto overflow-y-hidden px-1" :style="resultTabsScrollerStyle" @scroll="updateResultTabsScrollbar" @wheel="onResultTabsWheel">
<LightTooltip v-for="item in visibleResultItems" :key="item.index" :text="item.label || item.title || t('tabs.resultN', { n: item.n })" :disabled="!item.labelTruncated && !(!item.label && item.title)" :delay="150" :close-delay="0" nowrap>
<Button size="sm" :variant="activeOutputView === 'result' && (activeTab.activeResultIndex ?? 0) === item.index ? 'default' : 'ghost'" class="h-6 min-w-0 max-w-48 shrink-0 px-2 text-xs" :aria-label="item.label || t('tabs.resultN', { n: item.n })" @click="selectResultItem(item)">
<span class="block min-w-0 max-w-44 whitespace-nowrap">{{ item.displayLabel || item.label || t("tabs.resultN", { n: item.n }) }}</span>
</Button>
</LightTooltip>
<div role="tablist" :aria-label="t('tabs.resultRuns')" class="flex h-full shrink-0 items-center gap-1">
<div
v-for="(run, runIndex) in resultRuns"
:key="run.id"
role="presentation"
class="group/result-run inline-flex h-7 shrink-0 items-center overflow-hidden rounded-md border transition-colors"
:class="run.active ? 'border-border bg-background text-foreground shadow-sm' : 'border-transparent text-muted-foreground hover:border-border/70 hover:bg-background/70 hover:text-foreground'"
>
<button
type="button"
role="tab"
data-result-run-tab
:tabindex="run.active ? 0 : -1"
:aria-selected="run.active"
:data-active-result-run="run.active ? 'true' : undefined"
class="h-full whitespace-nowrap pl-2.5 pr-1 text-xs font-medium outline-none focus-visible:bg-accent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50"
@click="selectResultRun(run.id)"
@keydown="onResultRunTabKeydown($event, runIndex)"
>
{{ t("tabs.runN", { n: run.sequence }) }}
</button>
<button
type="button"
class="mr-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground/70 outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50"
:title="t('tabs.removeRun', { n: run.sequence })"
:aria-label="t('tabs.removeRun', { n: run.sequence })"
@click.stop.prevent="removeResultRun(run.id)"
>
<X class="h-3 w-3" />
</button>
</div>
</div>
</div>
</div>
<div v-else-if="showResultRunSelector" class="min-w-0 flex-1">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="sm" class="h-6 max-w-48 gap-1 px-2 text-xs">
<span class="min-w-0 truncate">{{ activeResultRunItem ? t("tabs.runN", { n: activeResultRunItem.sequence }) : t("tabs.resultRuns") }}</span>
<ChevronDown class="h-3.5 w-3.5 shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" class="w-48">
<DropdownMenuItem v-for="run in resultRuns" :key="run.id" class="flex items-center gap-2 pr-1" @select="selectResultRun(run.id)">
<Check v-if="run.active" class="h-3.5 w-3.5 shrink-0" />
<span v-else class="h-3.5 w-3.5 shrink-0" />
<span class="min-w-0 flex-1 truncate">{{ t("tabs.runN", { n: run.sequence }) }}</span>
<button
type="button"
class="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
:title="t('tabs.removeRun', { n: run.sequence })"
:aria-label="t('tabs.removeRun', { n: run.sequence })"
@click.stop.prevent="removeResultRun(run.id)"
>
<X class="h-3 w-3" />
</button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div v-else-if="resultRuns.length > 0" class="min-w-0 flex-1" />
<span v-if="resultRuns.length > 0 && visibleResultItems.length > 0" class="mx-1 h-4 w-px shrink-0 bg-border" />
<div v-if="visibleResultItems.length > 0" data-result-set-tabs-region role="group" :aria-label="t('tabs.resultSets')" class="flex h-full min-w-0 items-center gap-1" :class="resultRuns.length > 0 ? 'shrink-0' : 'flex-1 overflow-x-auto'">
<LightTooltip v-for="item in visibleResultItems" :key="item.index" :text="item.label || item.title || t('tabs.resultN', { n: item.n })" :disabled="!item.labelTruncated && !(!item.label && item.title)" :delay="150" :close-delay="0" nowrap>
<Button size="sm" :variant="activeOutputView === 'result' && (activeTab.activeResultIndex ?? 0) === item.index ? 'default' : 'ghost'" class="h-6 min-w-0 max-w-48 shrink-0 px-2 text-xs" :aria-label="item.label || t('tabs.resultN', { n: item.n })" @click="selectResultItem(item)">
<span class="block min-w-0 max-w-44 whitespace-nowrap">{{ item.displayLabel || item.label || t("tabs.resultN", { n: item.n }) }}</span>
</Button>
</LightTooltip>
</div>
</template>
<div class="ml-auto flex shrink-0 items-center gap-1">
<Popover v-if="activeOutputView === 'result' && activeTab.result && hasTabularResult && !activeElasticsearchJsonResponse">
@@ -945,6 +1032,30 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
<div class="border-b bg-muted/40 px-3 py-2">
<div class="text-xs font-semibold">{{ t("grid.viewOptions") }}</div>
</div>
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
<div class="min-w-0 flex items-center gap-2 font-medium">
<PanelsTopLeft class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span>{{ t("grid.resultRunDisplayMode") }}</span>
</div>
<div role="group" :aria-label="t('grid.resultRunDisplayMode')" class="grid w-32 grid-cols-2 rounded-md border bg-muted/40 p-0.5">
<button
type="button"
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
:class="resultRunDisplayMode === 'list' ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
@click="setResultRunDisplayMode('list')"
>
{{ t("grid.resultRunDisplayList") }}
</button>
<button
type="button"
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
:class="resultRunDisplayMode === 'tabs' ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
@click="setResultRunDisplayMode('tabs')"
>
{{ t("grid.resultRunDisplayTabs") }}
</button>
</div>
</div>
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
<div class="min-w-0 flex items-center gap-2 font-medium">
<SquareDashed class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />