feat(editor): add configurable join-lines shortcut
This commit is contained in:
13 files changed
+203
No files matched your search
@@ -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",
|
||||
"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");
|
||||
|
||||
@@ -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"
|
||||
| "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",
|
||||
|
||||
Reference in new issue
Block a user