feat(editor): add configurable line comment shortcut

This commit is contained in:
t8y2 2026-07-07 18:32:36 +08:00
parent 184e235999
commit 5a9ab5c036
13 changed files with 109 additions and 11 deletions

View File

@ -3,7 +3,7 @@ import { ref, watch, shallowRef, computed, onMounted, onUnmounted, nextTick } fr
import type { Ref } from "vue";
import type { EditorView as EditorViewType } from "@codemirror/view";
import { useI18n } from "vue-i18n";
import { AlertTriangle, CheckCircle2, CircleHelp, Cloud, Copy, Download, ExternalLink, GripVertical, Loader2, Moon, PackageSearch, Pencil, Plus, RefreshCw, RotateCcw, Settings, Sun, SunMoon, Terminal, Trash2, Upload, X } from "@lucide/vue";
import { AlertTriangle, CheckCircle2, CircleHelp, Cloud, Copy, Download, ExternalLink, GripVertical, Loader2, Moon, PackageSearch, Pencil, Plus, RefreshCw, RotateCcw, Search, Settings, Sun, SunMoon, Terminal, Trash2, Upload, X } from "@lucide/vue";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@ -617,8 +617,10 @@ const shortcutConflicts = computed(() =>
return conflict ? [definition.id] : [];
}),
);
const shortcutSearchQuery = ref("");
const formatterEditorShortcutIds: ShortcutActionId[] = [
"formatSql",
"toggleLineComment",
"find",
"replace",
"saveSql",
@ -638,6 +640,15 @@ const formatterEditorShortcutIds: ShortcutActionId[] = [
"lowercaseSelection",
];
const formatterEditorShortcutDefinitions = computed(() => formatterEditorShortcutIds.map((id) => SHORTCUT_DEFINITIONS.find((definition) => definition.id === id)).filter((definition): definition is (typeof SHORTCUT_DEFINITIONS)[number] => !!definition));
const filteredShortcutDefinitions = computed(() => {
const query = shortcutSearchQuery.value.trim().toLowerCase();
if (!query) return SHORTCUT_DEFINITIONS;
return SHORTCUT_DEFINITIONS.filter((definition) => {
const scope = t(`settings.shortcutScope${definition.scope[0].toUpperCase()}${definition.scope.slice(1)}`);
const shortcut = formatShortcutPill(editShortcuts.value[definition.id]);
return [definition.id, t(definition.labelKey), scope, shortcut].some((value) => value.toLowerCase().includes(query));
});
});
const hasShortcutConflicts = computed(() => shortcutConflicts.value.length > 0);
const shortcutsChanged = computed(() => JSON.stringify(editShortcuts.value) !== JSON.stringify(settingsStore.editorSettings.shortcuts));
const duckDbWorkerSettingsRequireRestart = computed(() => editDuckDbWorkerProcessIsolation.value !== startupDuckDbWorkerProcessIsolation.value || normalizeDuckDbWorkerMaxProcesses(editDuckDbWorkerMaxProcesses.value) !== startupDuckDbWorkerMaxProcesses.value);
@ -3407,8 +3418,15 @@ onUnmounted(cleanupPreviewEditor);
</section>
<section v-else-if="activeSettingsTab === 'shortcuts'" class="flex flex-col gap-2 py-2">
<div class="relative">
<Search class="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input v-model="shortcutSearchQuery" autocomplete="off" :placeholder="t('settings.shortcutSearchPlaceholder')" class="h-9 pl-9 text-sm" />
</div>
<div class="overflow-hidden rounded-md border border-border/70 bg-background">
<div v-for="definition in SHORTCUT_DEFINITIONS" :key="definition.id" class="group -mt-px grid gap-2 border-t border-border/70 px-3 py-2 transition-colors first:mt-0 first:border-t-0 hover:bg-muted/40 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<div v-if="filteredShortcutDefinitions.length === 0" class="px-3 py-8 text-center text-sm text-muted-foreground">
{{ t("settings.shortcutSearchNoResults") }}
</div>
<div v-for="definition in filteredShortcutDefinitions" :key="definition.id" class="group -mt-px grid gap-2 border-t border-border/70 px-3 py-2 transition-colors first:mt-0 first:border-t-0 hover:bg-muted/40 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<div class="min-w-0">
<div class="flex min-w-0 items-center gap-2">
<Label class="min-w-0 truncate leading-none">{{ t(definition.labelKey) }}</Label>

View File

@ -231,6 +231,7 @@ let codeMirrorUndo: typeof import("@codemirror/commands").undo | null = null;
let codeMirrorRedo: typeof import("@codemirror/commands").redo | null = null;
let codeMirrorSelectAll: typeof import("@codemirror/commands").selectAll | null = null;
let codeMirrorInsertNewlineKeepIndent: typeof import("@codemirror/commands").insertNewlineKeepIndent | null = null;
let codeMirrorToggleLineComment: typeof import("@codemirror/commands").toggleLineComment | null = null;
let setSqlDiagnosticsEffect: import("@codemirror/state").StateEffectType<SqlSemanticDiagnostic[]> | null = null;
let setPreviewRangeEffect: import("@codemirror/state").StateEffectType<{ from: number; to: number } | null> | null = null;
let previewRangeComp: import("@codemirror/state").Compartment | null = null;
@ -932,6 +933,7 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view")
...binding(shortcuts.selectAll, (view) => codeMirrorSelectAll?.(view) ?? false),
...binding(shortcuts.uppercaseSelection, () => convertSelectedSqlCase("upper")),
...binding(shortcuts.lowercaseSelection, () => convertSelectedSqlCase("lower")),
...binding(shortcuts.toggleLineComment, (view) => codeMirrorToggleLineComment?.(view) ?? false),
...binding(shortcuts.exPasteSqlInCondition, () => {
if (!supportsSqlInListPaste(props.databaseType)) return false;
void pasteClipboardAsSqlInCondition();
@ -2464,7 +2466,7 @@ onMounted(async () => {
{ EditorState, EditorSelection, Compartment, Prec, StateEffect, StateField },
langSql,
{ autocompletion, startCompletion, acceptCompletion, closeBrackets, closeBracketsKeymap, snippetCompletion, completionStatus, completionKeymap, insertCompletionText, nextSnippetField },
{ copyLineDown, copyLineUp, deleteLine, indentLess, indentMore, insertNewlineKeepIndent, moveLineDown, moveLineUp, redo, selectAll, undo, history, defaultKeymap, historyKeymap },
{ copyLineDown, copyLineUp, deleteLine, indentLess, indentMore, insertNewlineKeepIndent, moveLineDown, moveLineUp, redo, selectAll, undo, toggleLineComment, history, defaultKeymap, historyKeymap },
{ bracketMatching, foldGutter, indentOnInput, indentUnit, syntaxHighlighting, defaultHighlightStyle, foldKeymap },
{ searchKeymap },
] = await Promise.all([import("@codemirror/view"), import("@codemirror/state"), import("@codemirror/lang-sql"), import("@codemirror/autocomplete"), import("@codemirror/commands"), import("@codemirror/language"), import("@codemirror/search")]);
@ -2500,6 +2502,7 @@ onMounted(async () => {
codeMirrorRedo = redo;
codeMirrorSelectAll = selectAll;
codeMirrorInsertNewlineKeepIndent = insertNewlineKeepIndent;
codeMirrorToggleLineComment = toggleLineComment;
codeMirrorIndentUnit = indentUnit;
window.addEventListener("keyup", clearTableNavigationHoverOnModifierRelease);
window.addEventListener("blur", clearTableNavigationHover);
@ -2725,6 +2728,7 @@ onMounted(async () => {
dropCursor(),
EditorView.dragMovesSelection.of((event) => !event.ctrlKey && !event.metaKey),
EditorState.allowMultipleSelections.of(true),
EditorView.clickAddsSelectionRange.of((event) => event.altKey && event.button === 0),
indentOnInput(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
crosshairCursor(),

View File

@ -3041,6 +3041,7 @@ export default {
shortcutSaveSql: "Save SQL",
shortcutAcceptCompletion: "Accept completion",
shortcutFormatSql: "Format SQL",
shortcutToggleLineComment: "Toggle line comment",
shortcutIndentMore: "Indent more",
shortcutIndentLess: "Indent less",
shortcutDuplicateLine: "Duplicate current line",
@ -3086,6 +3087,8 @@ export default {
shortcutScopeSearch: "Search fields",
shortcutScopeSidebar: "Sidebar",
shortcutPressShortcut: "Press shortcut",
shortcutSearchPlaceholder: "Search shortcuts",
shortcutSearchNoResults: "No shortcuts match your search.",
shortcutConflict: "This shortcut conflicts with another action in the same scope.",
shortcutClear: "Clear shortcut",
preview: "Live Preview",

View File

@ -2952,6 +2952,7 @@ export default withEnglishFallback({
shortcutSaveSql: "Guardar SQL",
shortcutAcceptCompletion: "Aceptar completado",
shortcutFormatSql: "Formatear SQL",
shortcutToggleLineComment: "Alternar comentario de línea",
shortcutIndentMore: "Aumentar sangría",
shortcutIndentLess: "Reducir sangría",
shortcutDuplicateLine: "Duplicar línea actual",
@ -2997,6 +2998,8 @@ export default withEnglishFallback({
shortcutScopeSearch: "Campos de búsqueda",
shortcutScopeSidebar: "Barra lateral",
shortcutPressShortcut: "Presiona un atajo",
shortcutSearchPlaceholder: "Buscar atajos",
shortcutSearchNoResults: "Ningún atajo coincide con la búsqueda.",
shortcutConflict: "Este atajo entra en conflicto con otra acción del mismo ámbito.",
shortcutClear: "Borrar atajo",
preview: "Vista previa en tiempo real",

View File

@ -2950,6 +2950,7 @@ export default withEnglishFallback({
shortcutSaveSql: "Salva SQL",
shortcutAcceptCompletion: "Accetta completamento",
shortcutFormatSql: "Formatta SQL",
shortcutToggleLineComment: "Attiva/disattiva commento di riga",
shortcutIndentMore: "Aumenta rientro",
shortcutIndentLess: "Riduci rientro",
shortcutDuplicateLine: "Duplica riga corrente",
@ -2995,6 +2996,8 @@ export default withEnglishFallback({
shortcutScopeSearch: "Cerca campi",
shortcutScopeSidebar: "Barra laterale",
shortcutPressShortcut: "Premi scorciatoia",
shortcutSearchPlaceholder: "Cerca scorciatoie",
shortcutSearchNoResults: "Nessuna scorciatoia corrisponde alla ricerca.",
shortcutConflict: "Questa scorciatoia è in conflitto con un'altra azione nello stesso ambito.",
shortcutClear: "Cancella scorciatoia",
preview: "Anteprima in tempo reale",

View File

@ -2934,6 +2934,7 @@ export default withEnglishFallback({
shortcutSaveSql: "SQLを保存",
shortcutAcceptCompletion: "補完を受け入れる",
shortcutFormatSql: "SQLをフォーマット",
shortcutToggleLineComment: "行コメントを切り替え",
shortcutIndentMore: "インデントを増やす",
shortcutIndentLess: "インデントを減らす",
shortcutDuplicateLine: "現在行を複製",
@ -2979,6 +2980,8 @@ export default withEnglishFallback({
shortcutScopeSearch: "フィールド検索",
shortcutScopeSidebar: "サイドバー",
shortcutPressShortcut: "ショートカットを押してください",
shortcutSearchPlaceholder: "ショートカットを検索",
shortcutSearchNoResults: "一致するショートカットはありません。",
shortcutConflict: "このショートカットは同じスコープ内の別のアクションと競合しています。",
shortcutClear: "ショートカットをクリア",
preview: "ライブプレビュー",

View File

@ -2951,6 +2951,7 @@ export default withEnglishFallback({
shortcutSaveSql: "Salvar SQL",
shortcutAcceptCompletion: "Aceitar autocompletar",
shortcutFormatSql: "Formatar SQL",
shortcutToggleLineComment: "Alternar comentário de linha",
shortcutIndentMore: "Aumentar recuo",
shortcutIndentLess: "Reduzir recuo",
shortcutDuplicateLine: "Duplicar linha atual",
@ -2996,6 +2997,8 @@ export default withEnglishFallback({
shortcutScopeSearch: "Campos de pesquisa",
shortcutScopeSidebar: "Barra lateral",
shortcutPressShortcut: "Pressione o atalho",
shortcutSearchPlaceholder: "Pesquisar atalhos",
shortcutSearchNoResults: "Nenhum atalho corresponde à pesquisa.",
shortcutConflict: "Este atalho conflita com outra ação no mesmo escopo.",
shortcutClear: "Limpar atalho",
preview: "Pré-visualização ao vivo",

View File

@ -3041,6 +3041,7 @@ export default withEnglishFallback({
shortcutSaveSql: "保存 SQL",
shortcutAcceptCompletion: "接受补全",
shortcutFormatSql: "格式化 SQL",
shortcutToggleLineComment: "切换行注释",
shortcutIndentMore: "增加缩进",
shortcutIndentLess: "减少缩进",
shortcutDuplicateLine: "复制当前行",
@ -3089,6 +3090,8 @@ export default withEnglishFallback({
shortcutScopeSearch: "搜索框",
shortcutScopeSidebar: "侧边栏",
shortcutPressShortcut: "按下快捷键",
shortcutSearchPlaceholder: "搜索快捷键",
shortcutSearchNoResults: "没有匹配的快捷键。",
shortcutConflict: "这个快捷键与同一作用域内的其他操作冲突。",
shortcutClear: "清除快捷键",
preview: "实时预览",

View File

@ -2838,6 +2838,7 @@ export default withEnglishFallback({
shortcutSaveSql: "儲存 SQL",
shortcutAcceptCompletion: "接受補全",
shortcutFormatSql: "格式化 SQL",
shortcutToggleLineComment: "切換行註解",
shortcutIndentMore: "增加縮排",
shortcutIndentLess: "減少縮排",
shortcutDuplicateLine: "複製目前行",
@ -2883,6 +2884,8 @@ export default withEnglishFallback({
shortcutScopeSearch: "搜尋框",
shortcutScopeSidebar: "側邊欄",
shortcutPressShortcut: "按下快速鍵",
shortcutSearchPlaceholder: "搜尋快速鍵",
shortcutSearchNoResults: "沒有符合的快速鍵。",
shortcutConflict: "這個快速鍵與同一作用域內的其他操作衝突。",
shortcutClear: "清除快速鍵",
preview: "即時預覽",

View File

@ -34,4 +34,8 @@ describe("shortcut display", () => {
expect(formatShortcutDisplay("Mod+Plus", "Win32")).toBe("Ctrl + +");
expect(formatShortcutDisplay("Shift+Mod++", "Win32")).toBe("Ctrl + Shift + +");
});
it("displays multi-stroke shortcuts", () => {
expect(formatShortcutDisplay("Ctrl+K Ctrl+C", "Win32")).toBe("Ctrl + K, Ctrl + C");
});
});

View File

@ -2,7 +2,24 @@ import { describe, expect, it } from "vitest";
import { DEFAULT_SHORTCUT_SETTINGS, SHORTCUT_DEFINITIONS, findShortcutConflict, formatShortcut, normalizeShortcutSettings, shortcutToCodeMirrorKey, type ShortcutActionId } from "@/lib/editor/shortcutRegistry";
describe("shortcutRegistry editor actions", () => {
const formatterEditorActionIds: ShortcutActionId[] = ["formatSql", "indentMore", "indentLess", "duplicateLine", "deleteLine", "moveLineUp", "moveLineDown", "copyLineUp", "copyLineDown", "undo", "redo", "selectAll", "uppercaseSelection", "lowercaseSelection", "exPasteSqlInCondition"];
const formatterEditorActionIds: ShortcutActionId[] = [
"formatSql",
"toggleLineComment",
"indentMore",
"indentLess",
"duplicateLine",
"deleteLine",
"moveLineUp",
"moveLineDown",
"copyLineUp",
"copyLineDown",
"undo",
"redo",
"selectAll",
"uppercaseSelection",
"lowercaseSelection",
"exPasteSqlInCondition",
];
const sidebarShortcutActionIds: ShortcutActionId[] = ["copySidebarSelection", "pasteSidebarSelection", "editSidebarConnection"];
it("registers formatter editor shortcuts in the generic editor scope", () => {
@ -19,6 +36,7 @@ describe("shortcutRegistry editor actions", () => {
expect(shortcuts.executeSql).toBe("Mod+Shift+Enter");
expect(shortcuts.formatSql).toBe("Shift+Mod+F");
expect(shortcuts.toggleLineComment).toBe("Mod+/");
expect(shortcuts.indentMore).toBe("");
expect(shortcuts.indentLess).toBe("Shift+Tab");
expect(shortcuts.duplicateLine).toBe("Mod+D");
@ -71,4 +89,12 @@ describe("shortcutRegistry editor actions", () => {
expect(shortcutToCodeMirrorKey("Mod+Plus")).toBe("Mod-+");
expect(shortcutToCodeMirrorKey("Shift+Mod++")).toBe("Shift-Mod-+");
});
it("converts slash shortcuts for CodeMirror keymaps", () => {
expect(shortcutToCodeMirrorKey("Mod+/")).toBe("Mod-/");
});
it("converts multi-stroke shortcuts for CodeMirror keymaps", () => {
expect(shortcutToCodeMirrorKey("Ctrl+K Ctrl+C")).toBe("Ctrl-k Ctrl-c");
});
});

View File

@ -13,6 +13,11 @@ export function parseShortcutParts(shortcut?: string): string[] {
return shortcut.split("+").filter(Boolean);
}
export function parseShortcutStrokes(shortcut?: string): string[][] {
if (!shortcut) return [];
return shortcut.trim().split(/\s+/).filter(Boolean).map(parseShortcutParts);
}
function shortcutDisplayOrder(parts: string[], platform = globalThis.navigator?.platform || ""): string[] {
if (parts.length <= 2 || isMacShortcutPlatform(platform)) return parts;
@ -43,6 +48,10 @@ export function shortcutDisplayParts(shortcut?: string, platform = globalThis.na
return shortcutDisplayOrder(parseShortcutParts(shortcut), platform);
}
export function shortcutDisplayStrokes(shortcut?: string, platform = globalThis.navigator?.platform || ""): string[][] {
return parseShortcutStrokes(shortcut).map((parts) => shortcutDisplayOrder(parts, platform));
}
export function shortcutKeyLabel(part: string, platform = globalThis.navigator?.platform || ""): string {
const isMac = isMacShortcutPlatform(platform);
if (part === "Mod") return isMac ? "⌘" : "Ctrl";
@ -65,10 +74,15 @@ export function shortcutKeyLabel(part: string, platform = globalThis.navigator?.
}
export function shortcutDisplayKeys(shortcut?: string, platform = globalThis.navigator?.platform || ""): string[] {
return shortcutDisplayParts(shortcut, platform).map((part) => shortcutKeyLabel(part, platform));
return shortcutDisplayStrokes(shortcut, platform)
.flat()
.map((part) => shortcutKeyLabel(part, platform));
}
export function formatShortcutDisplay(shortcut: string, platform = globalThis.navigator?.platform || ""): string {
if (!shortcut) return "—";
return shortcutDisplayKeys(shortcut, platform).join(isMacShortcutPlatform(platform) ? " " : " + ");
const keySeparator = isMacShortcutPlatform(platform) ? " " : " + ";
return shortcutDisplayStrokes(shortcut, platform)
.map((parts) => parts.map((part) => shortcutKeyLabel(part, platform)).join(keySeparator))
.join(", ");
}

View File

@ -1,8 +1,9 @@
import { parseShortcutParts, shortcutDisplayParts } from "@/lib/editor/shortcutDisplay";
import { parseShortcutStrokes, shortcutDisplayParts } from "@/lib/editor/shortcutDisplay";
export type ShortcutActionId =
| "executeSql"
| "formatSql"
| "toggleLineComment"
| "saveSql"
| "acceptCompletion"
| "indentMore"
@ -74,6 +75,12 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
scope: "editor",
defaultShortcut: "Shift+Mod+F",
},
{
id: "toggleLineComment",
labelKey: "settings.shortcutToggleLineComment",
scope: "editor",
defaultShortcut: "Mod+/",
},
{
id: "saveSql",
labelKey: "settings.shortcutSaveSql",
@ -359,10 +366,14 @@ export function normalizeShortcutSettings(settings?: Partial<ShortcutSettings>):
}
export function shortcutToCodeMirrorKey(shortcut: string): string {
return parseShortcutParts(shortcut)
.map((part) => (part.length === 1 ? part.toLowerCase() : part))
.map((part) => (part === "Plus" ? "+" : part))
.join("-");
return parseShortcutStrokes(shortcut)
.map((parts) =>
parts
.map((part) => (part.length === 1 ? part.toLowerCase() : part))
.map((part) => (part === "Plus" ? "+" : part))
.join("-"),
)
.join(" ");
}
export function formatShortcut(shortcut: string, platform = globalThis.navigator?.platform || ""): string {