Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions frontend/src/core/codemirror/keymaps/__tests__/vimrc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,28 @@ describe("parseVimrc", () => {
expect(mappings).toEqual([]);
expect(err.mock.calls.length).toEqual(0);
});

it("should parse set clipboard=unnamedplus", () => {
const content = `
set clipboard=unnamedplus
`;

const err = vi.fn();
const commands = parseVimrc(dedent(content), err);
expect(commands).toEqual([
{ name: "set", args: { option: "clipboard=unnamedplus" } },
]);
expect(err.mock.calls.length).toEqual(0);
});

it("should report error for set without arguments", () => {
const content = `
set
`;

const err = vi.fn();
const commands = parseVimrc(dedent(content), err);
expect(commands).toEqual([]);
expect(err.mock.calls.length).toEqual(1);
});
});
113 changes: 113 additions & 0 deletions frontend/src/core/codemirror/keymaps/vim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ export function vimKeymapExtension(): Extension[] {
},
},
]),
keymap.of([
{
key: "p",
run: (ev) => interceptVimPaste(ev, "p"),
},
{
key: "P",
run: (ev) => interceptVimPaste(ev, "P"),
},
]),
keymap.of([
{
// Ctrl-[ by default is to dedent
Expand Down Expand Up @@ -239,6 +249,17 @@ function applyVimCommands(vimCommands: VimCommand[]) {
"mapclear|nmapclear|vmapclear|imapclear".split("|").includes(command.name)
) {
mapclear(command);
} else if (command.name === "set") {
if (
command.args?.option === "clipboard=unnamedplus" ||
command.args?.option === "clipboard=unnamed"
) {
enableVimClipboardSync();
} else {
Logger.warn(
`Could not execute vimrc "set" command: unsupported option "${command.args?.option}"`,
);
}
} else {
Logger.warn(
`Could not execute vimrc command "${command.name}: unknown command"`,
Expand All @@ -253,6 +274,17 @@ interface ExtendedVim {
isRecording: boolean;
isPlaying: boolean;
};
registerController?: {
unnamedRegister: {
setText: (
text: string,
linewise?: boolean,
blockwise?: boolean,
) => void;
pushText: (text: string, linewise?: boolean) => void;
toString: () => string;
};
};
};
}

Expand All @@ -273,6 +305,87 @@ function isMacroActive() {
return Boolean(macroModeState.isRecording || macroModeState.isPlaying);
}

let _clipboardSyncEnabled = false;
let _origSetTextFn:
| ((text: string, linewise?: boolean, blockwise?: boolean) => void)
| null = null;

function enableVimClipboardSync(): void {
if (_clipboardSyncEnabled) {
return;
}
if (!isExtendedVim(Vim)) {
Logger.warn("enableVimClipboardSync: getVimGlobalState_ not available");
return;
}
const unnamedRegister =
Vim.getVimGlobalState_()?.registerController?.unnamedRegister;
if (!unnamedRegister) {
Logger.warn("enableVimClipboardSync: unnamedRegister not found");
return;
}
const origSetText = unnamedRegister.setText.bind(unnamedRegister);
const origPushText = unnamedRegister.pushText.bind(unnamedRegister);
// Saved before patching so the paste interceptor can update the register without also writing to the clipboard.
_origSetTextFn = origSetText;

unnamedRegister.setText = (text, linewise, blockwise) => {
origSetText(text, linewise, blockwise);
if (text) {
navigator.clipboard?.writeText(text).catch(() => {});
}
};
unnamedRegister.pushText = (text, linewise) => {
origPushText(text, linewise);
if (text) {
navigator.clipboard
?.writeText(unnamedRegister.toString())
.catch(() => {});
}
};

_clipboardSyncEnabled = true;
const isFirefox = /firefox/i.test(navigator.userAgent);
Logger.log(
`[vim] clipboard sync (unnamedplus) enabled${isFirefox ? " β€” Firefox will show a paste-permission popup on each p/P; workaround: set dom.events.testing.asyncClipboard=true in about:config (at your own risk)" : ""}`,
);
}

// Pulls system clipboard into the unnamed register on p/P, then lets vim paste normally.
function interceptVimPaste(view: EditorView, key: "p" | "P"): boolean {
if (!_clipboardSyncEnabled || !_origSetTextFn) {
return false;
}
if (!isInVimNormalMode(view)) {
return false;
}
const cm = getCM(view);
if (!cm || !hasVimState(cm)) {
return false;
}
// Don't intercept explicit register prefixes like "ap.
const registerName = (
cm.state.vim as { inputState?: { registerName?: string } }
).inputState?.registerName;
if (registerName && registerName !== '"') {
return false;
}
const origSet = _origSetTextFn;
navigator.clipboard
?.readText()
.then((text) => {
if (text) {
origSet(text, false, false);
}
Vim.handleKey(cm, key, "mapping");
})
.catch(() => {
// Clipboard unavailable β€” fall back to whatever is in the register.
Vim.handleKey(cm, key, "mapping");
});
return true;
}

class CodeMirrorVimSync {
private instances = new Set<EditorView>();
private isBroadcasting = false;
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/core/codemirror/keymaps/vimrc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ export const KnownCommands: { [key: string]: VimCommandSchema } = {
imapclear: {
mode: "insert",
},

set: {
args: ["option"],
},
};

export type ParseError = (msg: string) => void;
Expand Down
Loading