diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 4bcde99513..730c502669 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -22,6 +22,7 @@ import { canFormatSqlForDatabaseType, formatSqlForEditing, compressSqlText, type import { detectAndFormatStructured } from "@/lib/sql/autoFormat"; import { enabledSqlParameterSyntaxes, resolveSqlVariableSyntaxToggles } from "@/lib/sql/sqlVariableSyntax"; import { blankLineDeletionChanges, replaceSelectedEditorText } from "@/lib/editor/queryEditorTextEdits"; +import { joinQueryEditorLines } from "@/lib/editor/queryEditorJoinLines"; import { createSqlSignatureTooltipDom } from "@/lib/editor/sqlSignatureTooltip"; import { buildSqlInConditionFromPasteSource, insertTextForSqlInCondition } from "@/lib/sql/sqlInListPaste"; import { resolveSqlSingleQuoteKeyAction } from "@/lib/sql/sqlQuoteCaret"; @@ -1829,6 +1830,7 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view") ...binding(shortcuts.indentMore, (view) => codeMirrorIndentMore?.(view) ?? false), ...binding(shortcuts.indentLess, (view) => codeMirrorIndentLess?.(view) ?? false), ...binding(shortcuts.insertLineBelow, insertLineBelow), + ...binding(shortcuts.joinLines, joinQueryEditorLines), ...binding(shortcuts.duplicateLine, (view) => codeMirrorCopyLineDown?.(view) ?? false), ...binding(shortcuts.deleteLine, (view) => codeMirrorDeleteLine?.(view) ?? false), ...binding(shortcuts.moveLineUp, (view) => codeMirrorMoveLineUp?.(view) ?? false), diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 5a32ffb509..54cfc47aaa 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -6002,6 +6002,7 @@ export default { shortcutIndentMore: "Indent more", shortcutIndentLess: "Indent less", shortcutInsertLineBelow: "Insert line below", + shortcutJoinLines: "Join lines", shortcutDuplicateLine: "Duplicate current line", shortcutDeleteLine: "Delete current line", shortcutMoveLineUp: "Move line up", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 508f7bab37..5beb8c90a5 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -5706,6 +5706,7 @@ export default withEnglishFallback({ shortcutIndentMore: "Aumentar sangría", shortcutIndentLess: "Reducir sangría", shortcutInsertLineBelow: "Insertar una línea debajo", + shortcutJoinLines: "Unir líneas", shortcutDuplicateLine: "Duplicar línea actual", shortcutDeleteLine: "Eliminar línea actual", shortcutMoveLineUp: "Mover línea arriba", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index a19a3928e1..df371a5f95 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -5706,6 +5706,7 @@ export default withEnglishFallback({ shortcutIndentMore: "Aumenta rientro", shortcutIndentLess: "Riduci rientro", shortcutInsertLineBelow: "Inserisci una riga sotto", + shortcutJoinLines: "Unisci righe", shortcutDuplicateLine: "Duplica riga corrente", shortcutDeleteLine: "Elimina riga corrente", shortcutMoveLineUp: "Sposta riga su", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 63947176a0..68047b1bf5 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -5721,6 +5721,7 @@ export default withEnglishFallback({ shortcutIndentMore: "インデントを増やす", shortcutIndentLess: "インデントを減らす", shortcutInsertLineBelow: "下に行を挿入", + shortcutJoinLines: "行を結合", shortcutDuplicateLine: "現在行を複製", shortcutDeleteLine: "現在行を削除", shortcutMoveLineUp: "行を上に移動", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index b02b8dce77..3fe397c025 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -5476,6 +5476,7 @@ export default withEnglishFallback({ shortcutIndentMore: "들여쓰기 늘리기", shortcutIndentLess: "들여쓰기 줄이기", shortcutInsertLineBelow: "아래에 줄 삽입", + shortcutJoinLines: "줄 합치기", shortcutDuplicateLine: "현재 줄 복제", shortcutDeleteLine: "현재 줄 삭제", shortcutMoveLineUp: "줄 위로 이동", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 606365d09f..0683ee8b25 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -5708,6 +5708,7 @@ export default withEnglishFallback({ shortcutIndentMore: "Aumentar recuo", shortcutIndentLess: "Reduzir recuo", shortcutInsertLineBelow: "Inserir uma linha abaixo", + shortcutJoinLines: "Unir linhas", shortcutDuplicateLine: "Duplicar linha atual", shortcutDeleteLine: "Excluir linha atual", shortcutMoveLineUp: "Mover linha para cima", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 066e0e0b19..afc7edb273 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -5988,6 +5988,7 @@ export default withEnglishFallback({ shortcutIndentMore: "增加缩进", shortcutIndentLess: "减少缩进", shortcutInsertLineBelow: "在下方新增一行", + shortcutJoinLines: "合并行", shortcutDuplicateLine: "复制当前行", shortcutDeleteLine: "删除当前行", shortcutMoveLineUp: "上移当前行", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 6d85f562dc..901afbcb75 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -5032,6 +5032,7 @@ export default withEnglishFallback({ shortcutIndentMore: "增加縮排", shortcutIndentLess: "減少縮排", shortcutInsertLineBelow: "在下方新增一行", + shortcutJoinLines: "合併行", shortcutDuplicateLine: "複製目前行", shortcutDeleteLine: "刪除目前行", shortcutMoveLineUp: "上移目前行", diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorJoinLines.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorJoinLines.spec.ts new file mode 100644 index 0000000000..f0dcc66089 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorJoinLines.spec.ts @@ -0,0 +1,88 @@ +import { readFileSync } from "node:fs"; +import { EditorSelection, EditorState, type Extension, type Transaction } from "@codemirror/state"; +import { describe, expect, it, vi } from "vitest"; +import { joinQueryEditorLines } from "@/lib/editor/queryEditorJoinLines"; + +const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8"); + +function runJoinLines(doc: string, selection: EditorSelection | { anchor: number; head?: number }, extensions: Extension[] = []) { + let state = EditorState.create({ doc, selection, extensions }); + const dispatch = vi.fn((transaction: Transaction) => { + state = transaction.state; + }); + const handled = joinQueryEditorLines({ + get state() { + return state; + }, + dispatch, + } as never); + + return { dispatch, handled, state }; +} + +describe("joinQueryEditorLines", () => { + it("binds the configurable join-lines shortcut in QueryEditor", () => { + expect(queryEditorSource).toContain('import { joinQueryEditorLines } from "@/lib/editor/queryEditorJoinLines";'); + expect(queryEditorSource).toContain("...binding(shortcuts.joinLines, joinQueryEditorLines)"); + }); + + it("joins every selected line with one separating space", () => { + const doc = "SELECT id,\n name\nFROM users;"; + const result = runJoinLines(doc, EditorSelection.range(0, doc.length)); + + expect(result.handled).toBe(true); + expect(result.state.doc.toString()).toBe("SELECT id, name FROM users;"); + expect(result.state.selection.main.from).toBe(0); + expect(result.state.selection.main.to).toBe(result.state.doc.length); + }); + + it("joins only the lines touched by the selection", () => { + const doc = "SELECT\n id,\n name\nFROM users;"; + const selection = EditorSelection.range(doc.indexOf("id"), doc.indexOf("name") + "name".length); + const result = runJoinLines(doc, selection); + + expect(result.state.doc.toString()).toBe("SELECT\n id, name\nFROM users;"); + expect(result.state.sliceDoc(result.state.selection.main.from, result.state.selection.main.to)).toBe("id, name"); + }); + + it("joins the current line with the next line when the selection is empty", () => { + const doc = "SELECT id,\n name"; + const cursor = doc.indexOf("id"); + const result = runJoinLines(doc, { anchor: cursor }); + + expect(result.state.doc.toString()).toBe("SELECT id, name"); + expect(result.state.selection.main.anchor).toBe(cursor); + }); + + it("collapses blank lines and surrounding whitespace", () => { + const doc = "SELECT id, \n\n name"; + const result = runJoinLines(doc, EditorSelection.range(0, doc.length)); + + expect(result.state.doc.toString()).toBe("SELECT id, name"); + }); + + it("joins independent line pairs for multiple cursors", () => { + const doc = "a\n b\nc\n d"; + const selection = EditorSelection.create([EditorSelection.cursor(0), EditorSelection.cursor(doc.indexOf("c"))]); + const result = runJoinLines(doc, selection, [EditorState.allowMultipleSelections.of(true)]); + + expect(result.state.doc.toString()).toBe("a b\nc d"); + expect(result.state.selection.ranges).toHaveLength(2); + }); + + it("does not edit read-only documents", () => { + const doc = "SELECT id,\n name"; + const result = runJoinLines(doc, { anchor: 0 }, [EditorState.readOnly.of(true)]); + + expect(result.handled).toBe(false); + expect(result.dispatch).not.toHaveBeenCalled(); + expect(result.state.doc.toString()).toBe(doc); + }); + + it("returns false when there is no following or selected line to join", () => { + const result = runJoinLines("SELECT 1;", { anchor: 0 }); + + expect(result.handled).toBe(false); + expect(result.dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts b/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts index 714bf5b9d8..ab559dff08 100644 --- a/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts @@ -7,6 +7,7 @@ describe("shortcutRegistry editor actions", () => { "toggleLineComment", "indentMore", "indentLess", + "joinLines", "duplicateLine", "deleteLine", "moveLineUp", @@ -69,6 +70,17 @@ describe("shortcutRegistry editor actions", () => { expect(findShortcutConflict("insertLineBelow", DEFAULT_SHORTCUT_SETTINGS.insertLineBelow, DEFAULT_SHORTCUT_SETTINGS)).toBeNull(); }); + it("registers a conflict-free platform shortcut for joining lines", () => { + const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === "joinLines"); + + expect(definition).toMatchObject({ scope: "editor", defaultShortcut: "Mod+J" }); + expect(DEFAULT_SHORTCUT_SETTINGS.joinLines).toBe("Mod+J"); + expect(formatShortcut(DEFAULT_SHORTCUT_SETTINGS.joinLines, "MacIntel")).toBe("Cmd+J"); + expect(formatShortcut(DEFAULT_SHORTCUT_SETTINGS.joinLines, "Win32")).toBe("Ctrl+J"); + expect(shortcutToCodeMirrorKey(DEFAULT_SHORTCUT_SETTINGS.joinLines)).toBe("Mod-j"); + expect(findShortcutConflict("joinLines", DEFAULT_SHORTCUT_SETTINGS.joinLines, DEFAULT_SHORTCUT_SETTINGS)).toBeNull(); + }); + it("resolves the close-other-tabs default per platform and heals cross-platform synced defaults", () => { // 本测试环境(darwin):默认应为 macOS 组合 expect(DEFAULT_SHORTCUT_SETTINGS.closeOtherTabs).toBe(closeOtherTabsDefaultShortcut()); @@ -116,6 +128,7 @@ describe("shortcutRegistry editor actions", () => { expect(shortcuts.toggleLineComment).toBe("Mod+/"); expect(shortcuts.indentMore).toBe(""); expect(shortcuts.indentLess).toBe("Shift+Tab"); + expect(shortcuts.joinLines).toBe("Mod+J"); expect(shortcuts.duplicateLine).toBe("Mod+D"); expect(shortcuts.deleteLine).toBe("Shift+Mod+K"); expect(shortcuts.moveLineUp).toBe("Alt+ArrowUp"); diff --git a/apps/desktop/src/lib/editor/queryEditorJoinLines.ts b/apps/desktop/src/lib/editor/queryEditorJoinLines.ts new file mode 100644 index 0000000000..76fb54efd1 --- /dev/null +++ b/apps/desktop/src/lib/editor/queryEditorJoinLines.ts @@ -0,0 +1,85 @@ +import type { ChangeSpec, EditorState, Line } from "@codemirror/state"; +import type { Command } from "@codemirror/view"; + +interface LineSpan { + from: number; + to: number; +} + +function selectedLineSpans(state: EditorState): LineSpan[] { + const spans: LineSpan[] = []; + + for (const range of state.selection.ranges) { + const from = state.doc.lineAt(range.from).number; + let to = state.doc.lineAt(range.to).number; + if (from === to) { + if (to === state.doc.lines) continue; + to++; + } + + const previous = spans[spans.length - 1]; + if (previous && from <= previous.to) { + previous.to = Math.max(previous.to, to); + } else { + spans.push({ from, to }); + } + } + + return spans; +} + +function firstContentOffset(line: Line): number { + return line.text.search(/\S/); +} + +function contentEnd(line: Line): number { + return line.to - (line.text.match(/\s*$/)?.[0].length ?? 0); +} + +function joinChangesForSpan(state: EditorState, span: LineSpan): ChangeSpec[] { + const changes: ChangeSpec[] = []; + const firstLine = state.doc.line(span.from); + let hasContent = firstContentOffset(firstLine) >= 0; + let separatorFrom = hasContent ? contentEnd(firstLine) : firstLine.from; + let lastContentLine = hasContent ? span.from : 0; + + for (let lineNumber = span.from + 1; lineNumber <= span.to; lineNumber++) { + const line = state.doc.line(lineNumber); + const contentOffset = firstContentOffset(line); + if (contentOffset < 0) continue; + + changes.push({ + from: separatorFrom, + to: line.from + contentOffset, + insert: hasContent ? " " : "", + }); + hasContent = true; + separatorFrom = contentEnd(line); + lastContentLine = lineNumber; + } + + if (lastContentLine < span.to) { + const lastLine = state.doc.line(span.to); + if (separatorFrom < lastLine.to) changes.push({ from: separatorFrom, to: lastLine.to }); + } + + return changes; +} + +export const joinQueryEditorLines: Command = ({ state, dispatch }) => { + if (state.readOnly) return false; + + const changes = selectedLineSpans(state).flatMap((span) => joinChangesForSpan(state, span)); + if (changes.length === 0) return false; + + const changeSet = state.changes(changes); + dispatch( + state.update({ + changes: changeSet, + selection: state.selection.map(changeSet), + scrollIntoView: true, + userEvent: "input.joinlines", + }), + ); + return true; +}; diff --git a/apps/desktop/src/lib/editor/shortcutRegistry.ts b/apps/desktop/src/lib/editor/shortcutRegistry.ts index 40c485ca4a..948d0dd5e8 100644 --- a/apps/desktop/src/lib/editor/shortcutRegistry.ts +++ b/apps/desktop/src/lib/editor/shortcutRegistry.ts @@ -11,6 +11,7 @@ export type ShortcutActionId = | "indentMore" | "indentLess" | "insertLineBelow" + | "joinLines" | "duplicateLine" | "deleteLine" | "moveLineUp" @@ -148,6 +149,12 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [ scope: "editor", defaultShortcut: "Shift+Enter", }, + { + id: "joinLines", + labelKey: "settings.shortcutJoinLines", + scope: "editor", + defaultShortcut: "Mod+J", + }, { id: "duplicateLine", labelKey: "settings.shortcutDuplicateLine",