feat(editor): add configurable join-lines shortcut

This commit is contained in:
李子凡 authored and GitHub committed 2026-08-14 22:11:09 +08:00
1 parent 2b8a5c8897
commit 26084d12e1
13 files changed
+203

No files matched your search

@@ -22,6 +22,7 @@ import { canFormatSqlForDatabaseType, formatSqlForEditing, compressSqlText, type
import { detectAndFormatStructured } from "@/lib/sql/autoFormat"; import { detectAndFormatStructured } from "@/lib/sql/autoFormat";
import { enabledSqlParameterSyntaxes, resolveSqlVariableSyntaxToggles } from "@/lib/sql/sqlVariableSyntax"; import { enabledSqlParameterSyntaxes, resolveSqlVariableSyntaxToggles } from "@/lib/sql/sqlVariableSyntax";
import { blankLineDeletionChanges, replaceSelectedEditorText } from "@/lib/editor/queryEditorTextEdits"; import { blankLineDeletionChanges, replaceSelectedEditorText } from "@/lib/editor/queryEditorTextEdits";
import { joinQueryEditorLines } from "@/lib/editor/queryEditorJoinLines";
import { createSqlSignatureTooltipDom } from "@/lib/editor/sqlSignatureTooltip"; import { createSqlSignatureTooltipDom } from "@/lib/editor/sqlSignatureTooltip";
import { buildSqlInConditionFromPasteSource, insertTextForSqlInCondition } from "@/lib/sql/sqlInListPaste"; import { buildSqlInConditionFromPasteSource, insertTextForSqlInCondition } from "@/lib/sql/sqlInListPaste";
import { resolveSqlSingleQuoteKeyAction } from "@/lib/sql/sqlQuoteCaret"; 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.indentMore, (view) => codeMirrorIndentMore?.(view) ?? false),
...binding(shortcuts.indentLess, (view) => codeMirrorIndentLess?.(view) ?? false), ...binding(shortcuts.indentLess, (view) => codeMirrorIndentLess?.(view) ?? false),
...binding(shortcuts.insertLineBelow, insertLineBelow), ...binding(shortcuts.insertLineBelow, insertLineBelow),
...binding(shortcuts.joinLines, joinQueryEditorLines),
...binding(shortcuts.duplicateLine, (view) => codeMirrorCopyLineDown?.(view) ?? false), ...binding(shortcuts.duplicateLine, (view) => codeMirrorCopyLineDown?.(view) ?? false),
...binding(shortcuts.deleteLine, (view) => codeMirrorDeleteLine?.(view) ?? false), ...binding(shortcuts.deleteLine, (view) => codeMirrorDeleteLine?.(view) ?? false),
...binding(shortcuts.moveLineUp, (view) => codeMirrorMoveLineUp?.(view) ?? false), ...binding(shortcuts.moveLineUp, (view) => codeMirrorMoveLineUp?.(view) ?? false),
+1
View File
@@ -6002,6 +6002,7 @@ export default {
shortcutIndentMore: "Indent more", shortcutIndentMore: "Indent more",
shortcutIndentLess: "Indent less", shortcutIndentLess: "Indent less",
shortcutInsertLineBelow: "Insert line below", shortcutInsertLineBelow: "Insert line below",
shortcutJoinLines: "Join lines",
shortcutDuplicateLine: "Duplicate current line", shortcutDuplicateLine: "Duplicate current line",
shortcutDeleteLine: "Delete current line", shortcutDeleteLine: "Delete current line",
shortcutMoveLineUp: "Move line up", shortcutMoveLineUp: "Move line up",
+1
View File
@@ -5706,6 +5706,7 @@ export default withEnglishFallback({
shortcutIndentMore: "Aumentar sangría", shortcutIndentMore: "Aumentar sangría",
shortcutIndentLess: "Reducir sangría", shortcutIndentLess: "Reducir sangría",
shortcutInsertLineBelow: "Insertar una línea debajo", shortcutInsertLineBelow: "Insertar una línea debajo",
shortcutJoinLines: "Unir líneas",
shortcutDuplicateLine: "Duplicar línea actual", shortcutDuplicateLine: "Duplicar línea actual",
shortcutDeleteLine: "Eliminar línea actual", shortcutDeleteLine: "Eliminar línea actual",
shortcutMoveLineUp: "Mover línea arriba", shortcutMoveLineUp: "Mover línea arriba",
+1
View File
@@ -5706,6 +5706,7 @@ export default withEnglishFallback({
shortcutIndentMore: "Aumenta rientro", shortcutIndentMore: "Aumenta rientro",
shortcutIndentLess: "Riduci rientro", shortcutIndentLess: "Riduci rientro",
shortcutInsertLineBelow: "Inserisci una riga sotto", shortcutInsertLineBelow: "Inserisci una riga sotto",
shortcutJoinLines: "Unisci righe",
shortcutDuplicateLine: "Duplica riga corrente", shortcutDuplicateLine: "Duplica riga corrente",
shortcutDeleteLine: "Elimina riga corrente", shortcutDeleteLine: "Elimina riga corrente",
shortcutMoveLineUp: "Sposta riga su", shortcutMoveLineUp: "Sposta riga su",
+1
View File
@@ -5721,6 +5721,7 @@ export default withEnglishFallback({
shortcutIndentMore: "インデントを増やす", shortcutIndentMore: "インデントを増やす",
shortcutIndentLess: "インデントを減らす", shortcutIndentLess: "インデントを減らす",
shortcutInsertLineBelow: "下に行を挿入", shortcutInsertLineBelow: "下に行を挿入",
shortcutJoinLines: "行を結合",
shortcutDuplicateLine: "現在行を複製", shortcutDuplicateLine: "現在行を複製",
shortcutDeleteLine: "現在行を削除", shortcutDeleteLine: "現在行を削除",
shortcutMoveLineUp: "行を上に移動", shortcutMoveLineUp: "行を上に移動",
+1
View File
@@ -5476,6 +5476,7 @@ export default withEnglishFallback({
shortcutIndentMore: "들여쓰기 늘리기", shortcutIndentMore: "들여쓰기 늘리기",
shortcutIndentLess: "들여쓰기 줄이기", shortcutIndentLess: "들여쓰기 줄이기",
shortcutInsertLineBelow: "아래에 줄 삽입", shortcutInsertLineBelow: "아래에 줄 삽입",
shortcutJoinLines: "줄 합치기",
shortcutDuplicateLine: "현재 줄 복제", shortcutDuplicateLine: "현재 줄 복제",
shortcutDeleteLine: "현재 줄 삭제", shortcutDeleteLine: "현재 줄 삭제",
shortcutMoveLineUp: "줄 위로 이동", shortcutMoveLineUp: "줄 위로 이동",
+1
View File
@@ -5708,6 +5708,7 @@ export default withEnglishFallback({
shortcutIndentMore: "Aumentar recuo", shortcutIndentMore: "Aumentar recuo",
shortcutIndentLess: "Reduzir recuo", shortcutIndentLess: "Reduzir recuo",
shortcutInsertLineBelow: "Inserir uma linha abaixo", shortcutInsertLineBelow: "Inserir uma linha abaixo",
shortcutJoinLines: "Unir linhas",
shortcutDuplicateLine: "Duplicar linha atual", shortcutDuplicateLine: "Duplicar linha atual",
shortcutDeleteLine: "Excluir linha atual", shortcutDeleteLine: "Excluir linha atual",
shortcutMoveLineUp: "Mover linha para cima", shortcutMoveLineUp: "Mover linha para cima",
+1
View File
@@ -5988,6 +5988,7 @@ export default withEnglishFallback({
shortcutIndentMore: "增加缩进", shortcutIndentMore: "增加缩进",
shortcutIndentLess: "减少缩进", shortcutIndentLess: "减少缩进",
shortcutInsertLineBelow: "在下方新增一行", shortcutInsertLineBelow: "在下方新增一行",
shortcutJoinLines: "合并行",
shortcutDuplicateLine: "复制当前行", shortcutDuplicateLine: "复制当前行",
shortcutDeleteLine: "删除当前行", shortcutDeleteLine: "删除当前行",
shortcutMoveLineUp: "上移当前行", shortcutMoveLineUp: "上移当前行",
+1
View File
@@ -5032,6 +5032,7 @@ export default withEnglishFallback({
shortcutIndentMore: "增加縮排", shortcutIndentMore: "增加縮排",
shortcutIndentLess: "減少縮排", shortcutIndentLess: "減少縮排",
shortcutInsertLineBelow: "在下方新增一行", shortcutInsertLineBelow: "在下方新增一行",
shortcutJoinLines: "合併行",
shortcutDuplicateLine: "複製目前行", shortcutDuplicateLine: "複製目前行",
shortcutDeleteLine: "刪除目前行", shortcutDeleteLine: "刪除目前行",
shortcutMoveLineUp: "上移目前行", shortcutMoveLineUp: "上移目前行",
@@ -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();
});
});
@@ -7,6 +7,7 @@ describe("shortcutRegistry editor actions", () => {
"toggleLineComment", "toggleLineComment",
"indentMore", "indentMore",
"indentLess", "indentLess",
"joinLines",
"duplicateLine", "duplicateLine",
"deleteLine", "deleteLine",
"moveLineUp", "moveLineUp",
@@ -69,6 +70,17 @@ describe("shortcutRegistry editor actions", () => {
expect(findShortcutConflict("insertLineBelow", DEFAULT_SHORTCUT_SETTINGS.insertLineBelow, DEFAULT_SHORTCUT_SETTINGS)).toBeNull(); 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", () => { it("resolves the close-other-tabs default per platform and heals cross-platform synced defaults", () => {
// 本测试环境(darwin):默认应为 macOS 组合 // 本测试环境(darwin):默认应为 macOS 组合
expect(DEFAULT_SHORTCUT_SETTINGS.closeOtherTabs).toBe(closeOtherTabsDefaultShortcut()); expect(DEFAULT_SHORTCUT_SETTINGS.closeOtherTabs).toBe(closeOtherTabsDefaultShortcut());
@@ -116,6 +128,7 @@ describe("shortcutRegistry editor actions", () => {
expect(shortcuts.toggleLineComment).toBe("Mod+/"); expect(shortcuts.toggleLineComment).toBe("Mod+/");
expect(shortcuts.indentMore).toBe(""); expect(shortcuts.indentMore).toBe("");
expect(shortcuts.indentLess).toBe("Shift+Tab"); expect(shortcuts.indentLess).toBe("Shift+Tab");
expect(shortcuts.joinLines).toBe("Mod+J");
expect(shortcuts.duplicateLine).toBe("Mod+D"); expect(shortcuts.duplicateLine).toBe("Mod+D");
expect(shortcuts.deleteLine).toBe("Shift+Mod+K"); expect(shortcuts.deleteLine).toBe("Shift+Mod+K");
expect(shortcuts.moveLineUp).toBe("Alt+ArrowUp"); expect(shortcuts.moveLineUp).toBe("Alt+ArrowUp");
@@ -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;
};
@@ -11,6 +11,7 @@ export type ShortcutActionId =
| "indentMore" | "indentMore"
| "indentLess" | "indentLess"
| "insertLineBelow" | "insertLineBelow"
| "joinLines"
| "duplicateLine" | "duplicateLine"
| "deleteLine" | "deleteLine"
| "moveLineUp" | "moveLineUp"
@@ -148,6 +149,12 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
scope: "editor", scope: "editor",
defaultShortcut: "Shift+Enter", defaultShortcut: "Shift+Enter",
}, },
{
id: "joinLines",
labelKey: "settings.shortcutJoinLines",
scope: "editor",
defaultShortcut: "Mod+J",
},
{ {
id: "duplicateLine", id: "duplicateLine",
labelKey: "settings.shortcutDuplicateLine", labelKey: "settings.shortcutDuplicateLine",