Files
dbx/apps/desktop/src/components/layout/SqlEditorWorkspace.vue
T

385 lines
19 KiB
Vue

<script setup lang="ts">
import { computed, inject, nextTick, onMounted, provide, ref, useAttrs, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
import "./sqlEditorWorkspace.css";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { createGroupTabBarPortal, GROUP_TAB_BAR_PORTAL } from "./groupTabBarPortal";
import { hasQueryOutput } from "@/lib/query/queryOutput";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import { Button } from "@/components/ui/button";
import EditorGroup from "./EditorGroup.vue";
import QueryResultSurface from "./QueryResultSurface.vue";
import { createContentSurfaceEventForwarders } from "@/lib/tabs/contentSurfaceEvents";
import type { ContentAreaSurfaceEmits, ContentAreaSurfaceProps, StatementRange } from "./querySurfaces";
import type { QueryTab } from "@/types/database";
defineOptions({ inheritAttrs: false });
const props = defineProps<
Omit<ContentAreaSurfaceProps, "activeTab"> & {
activeTab?: QueryTab;
showTabNavigation?: boolean;
tabBarWidth?: number;
tabBarCollapsed?: boolean;
canDetachTabs?: boolean;
detachedDropTarget?: boolean;
/** Render only the groups' tab strips (a plugin workbench tab owns the layout). */
contentSuppressed?: boolean;
}
>();
const emit = defineEmits<
ContentAreaSurfaceEmits & {
"locate-tab": [tab: QueryTab];
"toggle-zen-mode": [];
"start-resize": [event: PointerEvent];
"toggle-collapse": [];
"detach-tab": [tab: QueryTab];
}
>();
const attrs = useAttrs();
const workspaceClass = computed(() => (typeof attrs.class === "string" ? attrs.class : undefined));
const surfaceProps = computed(() => ({
activeTab: props.activeTab,
activeConnection: props.activeConnection,
executableSql: props.executableSql,
activeOutputView: props.activeOutputView,
formatSqlRequest: props.formatSqlRequest,
compressSqlRequest: props.compressSqlRequest,
selectedSql: props.selectedSql,
cursorPos: props.cursorPos,
blockDangerousRedisCommands: props.blockDangerousRedisCommands,
zenMode: props.zenMode,
}));
const contentEmits = createContentSurfaceEventForwarders(emit);
const editorGroupBindings = computed(() => ({ ...surfaceProps.value, ...contentEmits }));
const resultSurfaceBindings = computed(() => ({ ...surfaceProps.value, ...contentEmits, activeTab: activeTab.value! }));
defineExpose({
focusSearch: (target: Element | null = null) => {
const element = commandTargetElement(target);
// The shared result pane (and its Teleported cell-detail dialog) lives
// outside the group DOM, so route its search before the group fallback.
// A data-mode cell-detail dialog is portaled to body too but belongs to
// the group's grid — indistinguishable in the DOM, hence the gate.
if (element?.closest("[data-shared-result-surface]") || (showSharedResult.value && element?.closest("[data-cell-detail-editor-root]"))) {
return resultSurfaceRef.value?.focusSearch(element) ?? false;
}
const group = groupForElement(element) ?? activeEditorGroup();
return group?.focusSearch(element) ?? false;
},
openGoToColumn: () => activeEditorGroup()?.openGoToColumn() ?? false,
refreshData: (target: Element | null = null) => {
const element = commandTargetElement(target);
if (element?.closest("[data-shared-result-surface]")) {
return resultSurfaceRef.value?.refreshData() ?? false;
}
const group = groupForElement(element) ?? activeEditorGroup();
return group?.refreshData() ?? resultSurfaceRef.value?.refreshData() ?? false;
},
toggleResultsPane: () => toggleSharedResultsPane(),
refreshQueryEditorCompletionCache: () => activeEditorGroup()?.refreshQueryEditorCompletionCache() ?? false,
handleModRTarget: (target: Element) => {
// DataGrid, Elasticsearch JSON, and the cell-detail editor (Teleported
// outside the grid DOM) belong to the shared result surface while a query
// tab is active. Data-mode grids live inside a group's ContentArea and
// their cell-detail dialogs are portaled to body just the same, so route
// those to the group instead of the (unmounted) shared result surface.
if (showSharedResult.value && target.closest("[data-grid-root], [data-elasticsearch-json-response-root], [data-cell-detail-editor-root]")) {
return resultSurfaceRef.value?.handleModRTarget(target) ?? false;
}
const group = groupForElement(target) ?? activeEditorGroup();
return group?.handleModRTarget(target) ?? false;
},
requestQueryEditorExecute: (tabId?: string) => editorGroupForTab(tabId)?.requestQueryEditorExecute() ?? false,
captureQueryEditorExecutionSnapshot: (tabId?: string) => editorGroupForTab(tabId)?.captureQueryEditorExecutionSnapshot(),
requestQueryEditorExecuteInNewResultTab: (tabId?: string) => editorGroupForTab(tabId)?.requestQueryEditorExecuteInNewResultTab() ?? false,
requestQueryEditorPreviewChanges: (stackSql?: string) => activeEditorGroup()?.requestQueryEditorPreviewChanges(stackSql) ?? false,
shouldBlockQueryEditorExecutionShortcut: (event: KeyboardEvent) => activeEditorGroup()?.shouldBlockQueryEditorExecutionShortcut(event) ?? false,
cancelQueryEditorExecutionViewport: (requestId: number) => activeEditorGroup()?.cancelQueryEditorExecutionViewport(requestId) ?? false,
acceptQueryEditorExecutionViewport: (requestId: number) => activeEditorGroup()?.acceptQueryEditorExecutionViewport(requestId) ?? false,
pasteClipboardAsSqlInCondition: () => activeEditorGroup()?.pasteClipboardAsSqlInCondition() ?? Promise.resolve(false),
applyTableStructureChanges: () => {
const group = groupForElement(commandTargetElement(null));
return group?.applyTableStructureChanges() ?? activeEditorGroup()?.applyTableStructureChanges() ?? Promise.resolve(false);
},
insertRedisCommand: (command: string) => (groupForElement(commandTargetElement(null)) ?? activeEditorGroup())?.insertRedisCommand(command) ?? Promise.resolve(false),
executeRedisCommand: (command: string) => (groupForElement(commandTargetElement(null)) ?? activeEditorGroup())?.executeRedisCommand(command) ?? Promise.resolve(false),
});
const { t } = useI18n();
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
const isVerticalTabLayout = computed(() => settingsStore.editorSettings.tabPlacement === "left" || settingsStore.editorSettings.tabPlacement === "right");
const verticalTabsWithSuppressedContent = computed(() => props.contentSuppressed && isVerticalTabLayout.value);
const globalTabBarPortal = inject(GROUP_TAB_BAR_PORTAL, null);
const workspaceTabBarPortal = createGroupTabBarPortal(isVerticalTabLayout);
// A special page owns navigation while active. Otherwise side tabs stay
// outside the editor/result split, and horizontal tabs return to their group.
const providedPortal = {
active: computed(() => !!globalTabBarPortal?.active.value || isVerticalTabLayout.value),
get targets() {
return globalTabBarPortal?.active.value ? globalTabBarPortal.targets : workspaceTabBarPortal.targets;
},
};
provide(GROUP_TAB_BAR_PORTAL, providedPortal);
const tabNavigationStyle = computed(() => {
const width = props.tabBarCollapsed ? "var(--collapsed-tab-rail-width)" : `${props.tabBarWidth ?? 240}px`;
return { width, flex: `0 0 ${width}` };
});
function setTabBarTarget(groupId: string, element: unknown) {
if (element instanceof HTMLElement) workspaceTabBarPortal.targets.set(groupId, element);
else workspaceTabBarPortal.targets.delete(groupId);
}
const activeTab = computed(() => queryStore.tabs.find((tab) => tab.id === queryStore.activeTabId));
const showSharedResult = computed(() => activeTab.value?.mode === "query");
const hasSharedOutput = computed(() => showSharedResult.value && hasQueryOutput(activeTab.value));
const SHARED_RESULT_PANE_MIN_SIZE = 12;
const SHARED_RESULT_PANE_MAX_SIZE = 80;
const SHARED_RESULT_PANE_DEFAULT_SIZE = 32;
const SHARED_RESULT_PANE_STORAGE_KEY = "dbx-shared-results-pane-size";
const storedResultPaneSize = Number(safeLocalStorageGet(SHARED_RESULT_PANE_STORAGE_KEY));
const resultPaneSize = ref(Number.isFinite(storedResultPaneSize) && storedResultPaneSize >= SHARED_RESULT_PANE_MIN_SIZE && storedResultPaneSize <= SHARED_RESULT_PANE_MAX_SIZE ? storedResultPaneSize : SHARED_RESULT_PANE_DEFAULT_SIZE);
const showResultPane = computed({
get: () => activeTab.value?.uiState?.resultPaneOpen ?? true,
set: (open: boolean) => {
const tab = activeTab.value;
if (tab?.mode === "query") queryStore.updateTabUiState(tab.id, { resultPaneOpen: open });
},
});
const isResultPaneVisible = computed(() => hasSharedOutput.value && showResultPane.value);
// Keep the pane mounted, but apply its target size immediately so available
// results never wait for an expansion animation before becoming visible.
const resultPaneTargetSize = computed(() => (isResultPaneVisible.value ? resultPaneSize.value : 0));
const editorPaneSize = computed(() => 100 - resultPaneTargetSize.value);
/**
* Single toggle entry point for the shared result pane. Reached from the
* keyboard shortcut (exposed toggleResultsPane) and from the shared surface's
* "hide results" chevron, which bubbles a toggleResultsPane event up through
* ContentArea → QueryResultSurface. Non-query tabs (data etc.) render a plain
* ContentArea and keep their own per-tab resultsPaneOpen collapse behavior.
*/
function toggleSharedResultsPane(): boolean {
if (!showSharedResult.value) {
return activeEditorGroup()?.toggleResultsPane() ?? false;
}
if (!hasSharedOutput.value) return false;
showResultPane.value = !showResultPane.value;
return true;
}
// Restores the pre-split "running a query re-expands the results pane"
// behavior (see issue #6193): a collapsed shared pane comes back when the
// active tab starts executing.
watch(
() => [activeTab.value?.id, activeTab.value?.isExecuting, activeTab.value?.isExplaining] as const,
([, isExecuting, isExplaining]) => {
if (isExecuting || isExplaining) showResultPane.value = true;
},
);
// Entrance choreography is hydration-gated: groups present at first render
// never animate (no load choreography); only groups created later — by a
// split — materialize from their owner's side of the divider.
const hydrated = ref(false);
const initialGroupIds = new Set(queryStore.groups.map((group) => group.id));
onMounted(() => {
void nextTick(() => {
hydrated.value = true;
});
});
function paneEnterClass(groupId: string): string | undefined {
if (!hydrated.value || initialGroupIds.has(groupId)) {
return undefined;
}
return queryStore.orientation === "horizontal" ? "workspace-pane-enter workspace-pane-enter--from-top" : "workspace-pane-enter workspace-pane-enter--from-left";
}
function onSharedResultResized(payload: { panes: { size: number }[] }) {
const resultPane = payload.panes[1];
if (resultPane?.size != null && resultPane.size >= SHARED_RESULT_PANE_MIN_SIZE && resultPane.size <= SHARED_RESULT_PANE_MAX_SIZE) {
resultPaneSize.value = resultPane.size;
safeLocalStorageSet(SHARED_RESULT_PANE_STORAGE_KEY, String(resultPane.size));
}
}
const groupRefs = new Map<string, InstanceType<typeof EditorGroup>>();
const resultSurfaceRef = ref<InstanceType<typeof QueryResultSurface> | null>(null);
function setGroupRef(groupId: string, el: unknown) {
if (el) {
groupRefs.set(groupId, el as InstanceType<typeof EditorGroup>);
} else {
groupRefs.delete(groupId);
}
}
function activeEditorGroup() {
const group = queryStore.groups.find((item) => item.id === queryStore.focusedGroupId) ?? queryStore.groups[0];
return group ? (groupRefs.get(group.id) ?? null) : null;
}
function editorGroupForTab(tabId?: string): InstanceType<typeof EditorGroup> | null {
if (!tabId) return activeEditorGroup();
const group = queryStore.groups.find((item) => item.activeTabId === tabId);
return group ? (groupRefs.get(group.id) ?? null) : null;
}
function groupForElement(element: Element | null): InstanceType<typeof EditorGroup> | null {
const groupElement = element?.closest<HTMLElement>("[data-group-id]");
const groupId = groupElement?.dataset.groupId;
return groupId ? (groupRefs.get(groupId) ?? null) : null;
}
/**
* Resolves the command target element for a global keyboard command: the
* caller-supplied event target when available, otherwise the live focus.
*/
function commandTargetElement(target: Element | null): Element | null {
if (target) {
return target;
}
return document.activeElement instanceof Element ? document.activeElement : null;
}
function handlePreviewStatement(tabId: string, range: StatementRange | null): boolean {
for (const group of groupRefs.values()) {
if (group.previewStatementRange(tabId, range)) {
return true;
}
}
return false;
}
function handleFocusStatement(tabId: string, range: StatementRange | null): boolean {
for (const group of groupRefs.values()) {
if (group.focusStatementRange(tabId, range)) {
return true;
}
}
return false;
}
function handleFocusErrorOffset(tabId: string, offset: number): boolean {
for (const group of groupRefs.values()) {
if (group.focusErrorPosition(tabId, offset)) {
return true;
}
}
return false;
}
</script>
<template>
<div
class="sql-editor-workspace relative flex min-h-0 min-w-0 overflow-hidden"
:class="[verticalTabsWithSuppressedContent ? 'h-full flex-none' : contentSuppressed ? 'h-auto' : 'h-full flex-1', workspaceClass, settingsStore.editorSettings.tabPlacement === 'right' ? 'flex-row-reverse' : 'flex-row']"
>
<div v-show="isVerticalTabLayout && showTabNavigation !== false" data-workspace-tab-navigation class="flex min-h-0 shrink-0 flex-col overflow-hidden" :style="tabNavigationStyle">
<div v-for="group in queryStore.groups" :key="group.id" :ref="(element) => setTabBarTarget(group.id, element)" :data-workspace-tab-target="group.id" class="flex min-h-0 min-w-0 flex-1" @pointerdown.capture="queryStore.focusGroup(group.id)" @focusin="queryStore.focusGroup(group.id)" />
</div>
<div data-workspace-content class="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden" :class="{ hidden: verticalTabsWithSuppressedContent }">
<!-- Suppressed mode: a plugin workbench tab owns the layout. Render the
groups' tab strips directly (no Splitpanes, no shared result pane) so
App.vue's always-mounted plugin layer can fill the remaining column
while the strips stay visible and switchable. -->
<template v-if="contentSuppressed">
<EditorGroup
v-for="group in queryStore.groups"
:key="group.id"
:ref="(el: unknown) => setGroupRef(group.id, el)"
:group-id="group.id"
:tab-ids="group.tabIds"
:active-tab-id="group.activeTabId"
:tab-bar-width="tabBarWidth"
:tab-bar-collapsed="tabBarCollapsed"
:can-detach-tabs="canDetachTabs"
:detached-drop-target="detachedDropTarget"
:content-suppressed="true"
:show-tab-navigation="showTabNavigation"
v-bind="editorGroupBindings"
@focus-group="queryStore.focusGroup($event)"
@activate-tab="queryStore.activateTabInGroup(group.id, $event)"
@locate-tab="emit('locate-tab', $event)"
@toggle-zen-mode="emit('toggle-zen-mode')"
@start-resize="emit('start-resize', $event)"
@toggle-collapse="emit('toggle-collapse')"
@detach-tab="emit('detach-tab', $event)"
/>
</template>
<Splitpanes v-else horizontal class="sql-editor-workspace-split flex-1 min-h-0" :class="{ 'result-pane-collapsed': resultPaneTargetSize === 0 }" @resized="onSharedResultResized">
<Pane class="min-h-0 min-w-0" :size="editorPaneSize" :min-size="100 - SHARED_RESULT_PANE_MAX_SIZE">
<Splitpanes
:horizontal="queryStore.orientation === 'horizontal'"
class="sql-editor-groups h-full min-h-0"
@resized="
(event: { panes: Array<{ size: number }> }) => {
queryStore.sizes = event.panes.map((pane) => pane.size);
}
"
>
<Pane v-for="group in queryStore.groups" :key="group.id" :size="queryStore.sizes[queryStore.groups.indexOf(group)] ?? undefined" :min-size="10" class="min-h-0 min-w-0" :class="paneEnterClass(group.id)">
<EditorGroup
:ref="(el: unknown) => setGroupRef(group.id, el)"
:group-id="group.id"
:tab-ids="group.tabIds"
:active-tab-id="group.activeTabId"
:show-tab-navigation="showTabNavigation"
:tab-bar-width="tabBarWidth"
:tab-bar-collapsed="tabBarCollapsed"
:can-detach-tabs="canDetachTabs"
:detached-drop-target="detachedDropTarget"
class="h-full"
v-bind="editorGroupBindings"
@focus-group="queryStore.focusGroup($event)"
@activate-tab="queryStore.activateTabInGroup(group.id, $event)"
@locate-tab="emit('locate-tab', $event)"
@toggle-zen-mode="emit('toggle-zen-mode')"
@start-resize="emit('start-resize', $event)"
@toggle-collapse="emit('toggle-collapse')"
@detach-tab="emit('detach-tab', $event)"
>
<template #empty><slot name="empty" /></template>
</EditorGroup>
</Pane>
</Splitpanes>
</Pane>
<Pane class="min-h-0" :size="resultPaneTargetSize" :min-size="resultPaneTargetSize > 0 ? SHARED_RESULT_PANE_MIN_SIZE : 0" :max-size="SHARED_RESULT_PANE_MAX_SIZE">
<div v-if="isResultPaneVisible" data-shared-result-surface class="h-full min-h-0 overflow-hidden">
<QueryResultSurface
ref="resultSurfaceRef"
v-bind="resultSurfaceBindings"
class="h-full"
@toggle-results-pane="toggleSharedResultsPane"
@preview-statement="
(tabId: string, range: { from: number; to: number } | null) => {
handlePreviewStatement(tabId, range);
}
"
@focus-statement="
(tabId: string, range: { from: number; to: number } | null) => {
handleFocusStatement(tabId, range);
}
"
@focus-error-offset="
(tabId: string, offset: number) => {
handleFocusErrorOffset(tabId, offset);
}
"
/>
</div>
</Pane>
</Splitpanes>
<!-- The shared surface unmounts when collapsed, so the mouse re-show
affordance lives here, outside the collapsible pane. -->
<Button
v-if="hasSharedOutput && !showResultPane"
type="button"
variant="secondary"
size="sm"
class="absolute bottom-3 right-3 z-20 h-7 gap-1.5 rounded-full border bg-background/95 px-3 text-xs shadow-lg hover:bg-accent"
:title="t('editor.showResultsPane')"
:aria-label="t('editor.showResultsPane')"
@click="showResultPane = true"
>
{{ t("editor.showResultsPane") }}
</Button>
</div>
</div>
</template>