Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 16 additions & 16 deletions apps/desktop/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,21 @@
html.dark #root {
background-color: rgb(19 20 22);
}

/* This framework-independent fallback is painted while the detached WebView loads its Vue shell. */
#root:empty {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
font-family: system-ui, sans-serif;
color: #888;
font-size: 13px;
}

#root:empty::after {
content: "Loading…";
}
</style>
<script data-dbx-startup-theme>
(() => {
Expand Down Expand Up @@ -49,22 +64,7 @@
</script>
</head>
<body>
<div id="root">
<style>
#root:empty {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
font-family: system-ui, sans-serif;
color: #888;
font-size: 13px;
}
#root:empty::after {
content: "Loading…";
}
</style>
</div>
<div id="root"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
886 changes: 796 additions & 90 deletions apps/desktop/src/App.vue

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions apps/desktop/src/components/grid/DataGrid.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7562,6 +7562,21 @@ watch(
{ flush: "post" },
);

async function savePendingChangesForNavigation(): Promise<boolean> {
await saveChanges();
return !hasPendingChanges.value;
}

function hasPendingChangesForNavigation(): boolean {
commitEdit();
return hasPendingChanges.value;
}

function discardPendingChangesForNavigation(): boolean {
discardChanges();
return !hasPendingChanges.value;
}

defineExpose({
useTransaction,
transactionActive,
Expand Down Expand Up @@ -7597,6 +7612,9 @@ defineExpose({
exportSql,
exportXlsx,
exportTxt,
hasPendingChangesForNavigation,
savePendingChangesForNavigation,
discardPendingChangesForNavigation,
});

// ---- CustomContextMenu ----
Expand Down
100 changes: 96 additions & 4 deletions apps/desktop/src/components/layout/AppTabBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { computed, ref, watch, nextTick, onUnmounted } from "vue";
import type { CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
import { X, Pin, ChevronDown, Table2, Code2, TableProperties, PencilRuler, KeyRound, Pencil, Package, Lock, Copy, AlertTriangle, Network, Minimize2, Maximize2, Settings, CalendarClock, Activity, Gauge } from "@lucide/vue";
import { X, Pin, ChevronDown, Table2, Code2, TableProperties, PencilRuler, KeyRound, Pencil, Package, Lock, Copy, AlertTriangle, Network, Minimize2, Maximize2, Settings, CalendarClock, Activity, Gauge, ExternalLink } from "@lucide/vue";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
Expand All @@ -15,6 +15,7 @@ import { useSettingsStore } from "@/stores/settingsStore";
import { useTabScroll } from "@/composables/useTabScroll";
import { useTabDrag } from "@/composables/useTabDrag";
import { connectionColor, isConnectionReadonly, tabDisplayTitle, tabTooltipLines } from "@/lib/tabs/tabPresentation";
import { DETACHED_TAB_WINDOW_HEIGHT, DETACHED_TAB_WINDOW_WIDTH, detachedTabWindowPreviewRect, pointOutsideRect, type TabWindowClientPlacement } from "@/lib/tabs/tabWindowPlacement";
import { hexToRgba } from "@/lib/common/color";
import { copyToClipboard } from "@/lib/common/clipboard";
import { useToast } from "@/composables/useToast";
Expand All @@ -26,13 +27,15 @@ const props = defineProps<{
settingsPageOpen?: boolean;
settingsPageActive?: boolean;
agentDriverUpdateCount?: number;
detachableTabs?: boolean;
}>();

const emit = defineEmits<{
"activate-tab": [];
"activate-driver-store": [];
"close-driver-store": [];
"activate-settings-page": [];
"open-tab-window": [tabId: string, placement?: TabWindowClientPlacement, releasePreview?: () => void];
"close-settings-page": [];
"save-tab": [tabId: string];
"discard-tab-close": [];
Expand All @@ -46,8 +49,70 @@ const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
const { toast } = useToast();
const tabDrag = useTabDrag((draggedId, targetId, position) => {
queryStore.reorderTab(draggedId, targetId, position);
const tabBarRef = ref<HTMLElement | null>(null);
const retainedDetachedWindowPreview = ref<ReturnType<typeof detachedTabWindowPreviewRect> | null>(null);
const DETACHED_WINDOW_PREVIEW_TIMEOUT_MS = 20_000;
let detachedWindowPreviewGeneration = 0;
let detachedWindowPreviewTimer: ReturnType<typeof setTimeout> | null = null;

function retainDetachedWindowPreview(preview: ReturnType<typeof detachedTabWindowPreviewRect>): () => void {
const generation = ++detachedWindowPreviewGeneration;
retainedDetachedWindowPreview.value = preview;
if (detachedWindowPreviewTimer) clearTimeout(detachedWindowPreviewTimer);

const release = () => {
if (generation !== detachedWindowPreviewGeneration) return;
retainedDetachedWindowPreview.value = null;
if (detachedWindowPreviewTimer) {
clearTimeout(detachedWindowPreviewTimer);
detachedWindowPreviewTimer = null;
}
};
// Never leave a stale overlay behind if window startup stops responding.
detachedWindowPreviewTimer = setTimeout(release, DETACHED_WINDOW_PREVIEW_TIMEOUT_MS);
return release;
}

const tabDrag = useTabDrag(
(draggedId, targetId, position) => {
queryStore.reorderTab(draggedId, targetId, position);
},
{
onDetach: (tabId, event) => {
const preview = detachedTabWindowPreviewRect(
{ x: event.clientX, y: event.clientY },
{
width: document.documentElement.clientWidth,
height: document.documentElement.clientHeight,
},
);
// Keep visual continuity until the hidden native window has a renderable shell.
const releasePreview = retainDetachedWindowPreview(preview);
emit("open-tab-window", tabId, { left: preview.left, top: preview.top }, releasePreview);
},
shouldDetach: (event) => {
if (!props.detachableTabs) return false;
const rect = tabBarRef.value?.getBoundingClientRect();
if (!rect) return false;
// Keep a small edge tolerance so minor pointer drift does not detach a tab.
return pointOutsideRect({ x: event.clientX, y: event.clientY }, rect, 8);
},
},
);
const detachedWindowPreview = computed(() => {
if (props.detachableTabs && tabDrag.state.active) {
const tabBarRect = tabBarRef.value?.getBoundingClientRect();
if (tabBarRect && pointOutsideRect({ x: tabDrag.state.currentX, y: tabDrag.state.currentY }, tabBarRect, 8)) {
return detachedTabWindowPreviewRect(
{ x: tabDrag.state.currentX, y: tabDrag.state.currentY },
{
width: document.documentElement.clientWidth,
height: document.documentElement.clientHeight,
},
);
}
}
return retainedDetachedWindowPreview.value;
});
const editingTabId = ref<string | null>(null);
const editingTitle = ref("");
Expand Down Expand Up @@ -106,6 +171,10 @@ onUnmounted(() => {
clearTimeout(closeConfirmListCloseTimer);
closeConfirmListCloseTimer = null;
}
if (detachedWindowPreviewTimer) {
clearTimeout(detachedWindowPreviewTimer);
detachedWindowPreviewTimer = null;
}
});

watch(
Expand Down Expand Up @@ -269,6 +338,12 @@ function getTabMenuItems(tab: QueryTab): ContextMenuItem[] {
icon: Copy,
visible: canRenameTab(tab),
},
{
label: t("contextMenu.openInNewWindow"),
action: () => emit("open-tab-window", tab.id),
icon: ExternalLink,
visible: props.detachableTabs,
},
{
label: t("contextMenu.copyName"),
action: async () => {
Expand Down Expand Up @@ -579,7 +654,24 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
</script>

<template>
<div v-if="queryStore.tabs.length > 0 || driverStoreOpen || settingsPageOpen" class="app-tab-bar relative flex w-full min-w-0 shrink-0 overflow-hidden" :class="tabBarClass">
<Teleport to="body">
<div
v-if="detachedWindowPreview"
class="pointer-events-none fixed z-[9998] overflow-hidden rounded-xl border-2 border-primary/60 bg-primary/10 shadow-2xl backdrop-blur-[1px]"
:style="{
left: `${detachedWindowPreview.left}px`,
top: `${detachedWindowPreview.top}px`,
width: `${detachedWindowPreview.width}px`,
height: `${detachedWindowPreview.height}px`,
}"
>
<div class="flex h-9 items-center justify-between border-b border-primary/30 bg-background/80 px-3 text-xs font-medium text-foreground shadow-sm">
<span>{{ t("contextMenu.openInNewWindow") }}</span>
<span class="rounded bg-primary/10 px-2 py-0.5 font-mono text-[11px] text-muted-foreground"> {{ DETACHED_TAB_WINDOW_WIDTH }} × {{ DETACHED_TAB_WINDOW_HEIGHT }} </span>
</div>
</div>
</Teleport>
<div v-if="queryStore.tabs.length > 0 || driverStoreOpen || settingsPageOpen" ref="tabBarRef" class="app-tab-bar relative flex w-full min-w-0 shrink-0 overflow-hidden" :class="tabBarClass">
<div class="flex w-full min-w-0 shrink-0 overflow-hidden" :class="regularTabRowClass">
<div class="app-tab-strip relative h-full min-w-0 flex-1 overflow-hidden">
<div v-if="showRegularTabScrollbar" class="app-tab-scrollbar" :class="{ 'app-tab-scrollbar--dragging': isScrollbarDragging }" @pointerdown="startScrollbarDrag">
Expand Down
31 changes: 30 additions & 1 deletion apps/desktop/src/components/layout/ContentArea.vue
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ type DataGridHandle = {
onToolbarRefresh: () => Promise<void> | void;
focusSearch: () => boolean;
openCellDetailSearch: () => boolean;
hasPendingChangesForNavigation: () => boolean;
savePendingChangesForNavigation: () => Promise<boolean>;
discardPendingChangesForNavigation: () => boolean;
visibleColumnCount: number;
displayableColumnCount: number;
hiddenColumnCount: number;
Expand Down Expand Up @@ -910,6 +913,18 @@ function applyTableStructureChanges() {
return tableStructureEditorRef.value?.applyChanges() ?? Promise.resolve(false);
}

function hasPendingDataGridChanges() {
return dataGridRef.value?.hasPendingChangesForNavigation() ?? false;
}

function savePendingDataGridChanges() {
return dataGridRef.value?.savePendingChangesForNavigation() ?? Promise.resolve(false);
}

function discardPendingDataGridChanges() {
return dataGridRef.value?.discardPendingChangesForNavigation() ?? true;
}

async function insertRedisCommand(command: string): Promise<boolean> {
if (props.activeTab.mode !== "redis") return false;
return (await redisKeyBrowserRef.value?.insertCommand?.(command)) ?? false;
Expand All @@ -920,7 +935,21 @@ async function executeRedisCommand(command: string): Promise<boolean> {
return (await redisKeyBrowserRef.value?.executeCommand?.(command)) ?? false;
}

defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, handleModRTarget, requestQueryEditorExecute, requestQueryEditorExecuteInNewResultTab, pasteClipboardAsSqlInCondition, applyTableStructureChanges, insertRedisCommand, executeRedisCommand });
defineExpose({
focusSearch,
refreshData,
refreshQueryEditorCompletionCache,
handleModRTarget,
requestQueryEditorExecute,
requestQueryEditorExecuteInNewResultTab,
pasteClipboardAsSqlInCondition,
applyTableStructureChanges,
hasPendingDataGridChanges,
savePendingDataGridChanges,
discardPendingDataGridChanges,
insertRedisCommand,
executeRedisCommand,
});
</script>

<template>
Expand Down
101 changes: 101 additions & 0 deletions apps/desktop/src/components/layout/DetachedTabWindow.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<script setup lang="ts">
import { computed, ref, useAttrs } from "vue";
import { useI18n } from "vue-i18n";
import { X } from "@lucide/vue";
import EditorToolbar from "@/components/layout/EditorToolbar.vue";
import ContentArea from "@/components/layout/ContentArea.vue";
import { tabDisplayTitle } from "@/lib/tabs/tabPresentation";
import type { ConnectionConfig, QueryTab } from "@/types/database";

defineOptions({ inheritAttrs: false });

const props = defineProps<{
activeTab?: QueryTab;
activeConnection?: ConnectionConfig;
executableSql: string;
activeOutputView: "result" | "summary" | "explain" | "chart";
formatSqlRequest: { id: number; tabId: string } | null;
compressSqlRequest: { id: number; tabId: string } | null;
selectedSql: string;
cursorPos: number;
blockDangerousRedisCommands: boolean;
explainMode: "explain" | "autotrace";
sqlKeywordCase: "preserve" | "upper" | "lower";
autoCommit: boolean;
txnSessionId?: string;
txnAutoRolledBack?: boolean;
}>();

const emit = defineEmits<{
close: [];
}>();
const { t } = useI18n();
const attrs = useAttrs();
const contentAreaRef = ref<InstanceType<typeof ContentArea> | null>(null);
// useAttrs() exposes listeners as onXxx properties. Forward them through
// v-bind so object-form v-on does not transform their event names again.
const toolbarListeners = computed(() => Object.fromEntries(Object.entries(attrs).filter(([key]) => key.startsWith("on") && key !== "onExecute" && key !== "onExecuteInNewResultTab")));

defineExpose({
focusSearch: () => contentAreaRef.value?.focusSearch(),
refreshData: () => contentAreaRef.value?.refreshData(),
refreshQueryEditorCompletionCache: () => contentAreaRef.value?.refreshQueryEditorCompletionCache(),
handleModRTarget: (target: Element) => contentAreaRef.value?.handleModRTarget(target),
requestQueryEditorExecute: () => contentAreaRef.value?.requestQueryEditorExecute(),
requestQueryEditorExecuteInNewResultTab: () => contentAreaRef.value?.requestQueryEditorExecuteInNewResultTab(),
pasteClipboardAsSqlInCondition: () => contentAreaRef.value?.pasteClipboardAsSqlInCondition(),
applyTableStructureChanges: () => contentAreaRef.value?.applyTableStructureChanges(),
hasPendingDataGridChanges: () => contentAreaRef.value?.hasPendingDataGridChanges() ?? false,
savePendingDataGridChanges: () => contentAreaRef.value?.savePendingDataGridChanges() ?? Promise.resolve(false),
discardPendingDataGridChanges: () => contentAreaRef.value?.discardPendingDataGridChanges() ?? true,
insertRedisCommand: (command: string) => contentAreaRef.value?.insertRedisCommand(command),
executeRedisCommand: (command: string) => contentAreaRef.value?.executeRedisCommand(command),
});
</script>

<template>
<div class="fixed inset-0 flex h-screen w-screen min-w-0 flex-col overflow-hidden bg-background text-foreground">
<header class="flex h-10 shrink-0 items-center gap-2 border-b bg-muted/50 px-3" data-tauri-drag-region>
<span class="min-w-0 flex-1 truncate text-sm font-medium" data-tauri-drag-region>
{{ activeTab ? tabDisplayTitle(activeTab, t) : t("contextMenu.openInNewWindow") }}
</span>
<button type="button" class="rounded p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground" :aria-label="t('contextMenu.closeTab')" @click="emit('close')">
<X class="h-4 w-4" />
</button>
</header>

<div v-if="activeTab" class="flex min-h-0 flex-1 flex-col">
<EditorToolbar
v-if="activeTab.mode === 'query'"
:active-tab="activeTab"
:active-connection="activeConnection"
:executable-sql="executableSql"
:explain-mode="explainMode"
:sql-keyword-case="sqlKeywordCase"
:block-dangerous-redis-commands="blockDangerousRedisCommands"
:auto-commit="autoCommit"
:txn-session-id="txnSessionId"
:txn-auto-rolled-back="txnAutoRolledBack"
v-bind="toolbarListeners"
@execute="contentAreaRef?.requestQueryEditorExecute()"
/>
<ContentArea
ref="contentAreaRef"
class="min-h-0 flex-1"
:active-tab="activeTab"
:active-connection="activeConnection"
:executable-sql="executableSql"
:active-output-view="activeOutputView"
:format-sql-request="formatSqlRequest"
:compress-sql-request="compressSqlRequest"
:selected-sql="selectedSql"
:cursor-pos="cursorPos"
:block-dangerous-redis-commands="blockDangerousRedisCommands"
v-bind="$attrs"
/>
</div>
<div v-else class="flex flex-1 items-center justify-center" role="status" aria-label="Loading detached tab">
<span class="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground/25 border-t-primary" />
</div>
</div>
</template>
Loading