Implements the go back and forward keyboard shortcuts (#2789)

This commit is contained in:
Alexandre de Souza 2024-09-25 13:38:47 -03:00 committed by GitHub
parent a7caa429b0
commit aed88325c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 353 additions and 14 deletions

View file

@ -156,7 +156,9 @@ const Cell = {
if (event.type === "dispatch_queue_evaluation") {
this.handleDispatchQueueEvaluation(event.dispatch);
} else if (event.type === "jump_to_line") {
this.handleJumpToLine(event.line);
if (this.isFocused) {
this.currentEditor().moveCursorToLine(event.line, event.offset || 0);
}
}
},
@ -173,12 +175,6 @@ const Cell = {
}
},
handleJumpToLine(line) {
if (this.isFocused) {
this.currentEditor().moveCursorToLine(line);
}
},
handleCellEditorCreated(tag, liveEditor) {
this.liveEditors[tag] = liveEditor;
@ -211,10 +207,18 @@ const Cell = {
// gives it focus
if (!this.isFocused || !this.insertMode) {
this.currentEditor().blur();
} else {
this.sendCursorHistory();
}
}, 0);
});
liveEditor.onSelectionChange(() => {
if (this.isFocused) {
this.sendCursorHistory();
}
});
if (tag === "primary") {
const source = liveEditor.getSource();
@ -370,6 +374,17 @@ const Cell = {
});
});
},
sendCursorHistory() {
const cursor = this.currentEditor().getCurrentCursorPosition();
if (cursor === null) return;
globalPubsub.broadcast("history", {
...cursor,
type: "navigation",
cellId: this.props.cellId,
});
},
};
export default Cell;

View file

@ -99,6 +99,14 @@ export default class LiveEditor {
*/
onFocus = this._onFocus.event;
/** @private */
_onSelectionChange = new Emitter();
/**
* Registers a callback called whenever the editor changes selection.
*/
onSelectionChange = this._onSelectionChange.event;
constructor(
container,
connection,
@ -166,6 +174,21 @@ export default class LiveEditor {
return node.parentElement;
}
/**
* Returns the current main cursor position.
*/
getCurrentCursorPosition() {
if (!this.isMounted()) {
return null;
}
const pos = this.view.state.selection.main.head;
const line = this.view.state.doc.lineAt(pos);
const offset = pos - line.from;
return { line: line.number, offset };
}
/**
* Focuses the editor.
*
@ -183,11 +206,12 @@ export default class LiveEditor {
/**
* Updates editor selection such that cursor points to the given line.
*/
moveCursorToLine(lineNumber) {
moveCursorToLine(lineNumber, offset) {
const line = this.view.state.doc.line(lineNumber);
const position = line.from + offset;
this.view.dispatch({
selection: EditorSelection.single(line.from),
selection: EditorSelection.single(position),
});
}
@ -308,6 +332,10 @@ export default class LiveEditor {
{ key: "Alt-Enter", run: insertBlankLineAndCloseHints },
];
const selectionChangeListener = EditorView.updateListener.of((update) =>
this.handleViewUpdate(update),
);
this.view = new EditorView({
parent: this.container,
doc: this.source,
@ -369,6 +397,7 @@ export default class LiveEditor {
focus: this.handleEditorFocus.bind(this),
}),
EditorView.clickAddsSelectionRange.of((event) => event.altKey),
selectionChangeListener,
],
});
}
@ -389,7 +418,6 @@ export default class LiveEditor {
// We dispatch escape event, but only if it is not consumed by any
// registered handler in the editor, such as closing autocompletion
// or escaping Vim insert mode
if (event.key === "Escape") {
this.container.dispatchEvent(
new CustomEvent("lb:editor_escape", { bubbles: true }),
@ -415,6 +443,13 @@ export default class LiveEditor {
return false;
}
/** @private */
handleViewUpdate(update) {
if (!update.state.selection.eq(update.startState.selection)) {
this._onSelectionChange.dispatch();
}
}
/** @private */
completionSource(context) {
const settings = settingsStore.get();

View file

@ -18,6 +18,7 @@ import { leaveChannel } from "./js_view/channel";
import { isDirectlyEditable, isEvaluable } from "../lib/notebook";
import { settingsStore } from "../lib/settings";
import { LiveStore } from "../lib/live_store";
import CursorHistory from "./session/cursor_history";
/**
* A hook managing the whole session.
@ -81,6 +82,7 @@ const Session = {
this.viewOptions = null;
this.keyBuffer = new KeyBuffer();
this.lastLocationReportByClientId = {};
this.cursorHistory = new CursorHistory();
this.followedClientId = null;
this.store = LiveStore.create("session");
@ -161,6 +163,7 @@ const Session = {
globalPubsub.subscribe("jump_to_editor", ({ line, file }) =>
this.jumpToLine(file, line),
),
globalPubsub.subscribe("history", this.handleHistoryEvent.bind(this)),
];
this.initializeDragAndDrop();
@ -304,6 +307,7 @@ const Session = {
}
const cmd = isMacOS() ? event.metaKey : event.ctrlKey;
const ctrl = event.ctrlKey;
const alt = event.altKey;
const shift = event.shiftKey;
const key = event.key;
@ -316,7 +320,16 @@ const Session = {
event.target.closest(`[data-el-outputs-container]`)
)
) {
if (cmd && shift && !alt && key === "Enter") {
// On macOS, ctrl+alt+- becomes an em-dash, so we check for the code
if (event.code === "Minus" && ctrl && alt) {
cancelEvent(event);
this.cursorHistoryGoBack();
return;
} else if (key === "=" && ctrl && alt) {
cancelEvent(event);
this.cursorHistoryGoForward();
return;
} else if (cmd && shift && !alt && key === "Enter") {
cancelEvent(event);
this.queueFullCellsEvaluation(true);
return;
@ -1227,6 +1240,8 @@ const Session = {
},
handleCellDeleted(cellId, siblingCellId) {
this.cursorHistory.removeAllFromCell(cellId);
if (this.focusedId === cellId) {
if (this.view) {
const visibleSiblingId = this.ensureVisibleFocusableEl(siblingCellId);
@ -1324,6 +1339,12 @@ const Session = {
}
},
handleHistoryEvent(event) {
if (event.type === "navigation") {
this.cursorHistory.push(event.cellId, event.line, event.offset);
}
},
repositionJSViews() {
globalPubsub.broadcast("js_views", { type: "reposition" });
},
@ -1447,12 +1468,39 @@ const Session = {
jumpToLine(file, line) {
const [_filename, cellId] = file.split("#cell:");
this.setFocusedEl(cellId, { scroll: false });
this.setInsertMode(true);
globalPubsub.broadcast(`cells:${cellId}`, { type: "jump_to_line", line });
},
cursorHistoryGoBack() {
if (this.cursorHistory.canGoBack()) {
const { cellId, line, offset } = this.cursorHistory.goBack();
this.setFocusedEl(cellId, { scroll: false });
this.setInsertMode(true);
globalPubsub.broadcast(`cells:${cellId}`, {
type: "jump_to_line",
line,
offset,
});
}
},
cursorHistoryGoForward() {
if (this.cursorHistory.canGoForward()) {
const { cellId, line, offset } = this.cursorHistory.goForward();
this.setFocusedEl(cellId, { scroll: false });
this.setInsertMode(true);
globalPubsub.broadcast(`cells:${cellId}`, {
type: "jump_to_line",
line,
offset,
});
}
},
};
export default Session;

View file

@ -0,0 +1,133 @@
/**
* Allows for recording a sequence of focused cells with the focused line
* and navigate inside this stack.
*/
export default class CursorHistory {
/** @private */
entries = [];
/** @private */
index = -1;
/**
* Adds a new cell to the stack.
*
* If the stack length is greater than the stack limit,
* it will remove the oldest entries.
*/
push(cellId, line, offset) {
const entry = { cellId, line, offset };
if (this.isSameCell(cellId)) {
this.entries[this.index] = entry;
} else {
if (this.entries[this.index + 1] !== undefined) {
this.entries = this.entries.slice(0, this.index + 1);
}
this.entries.push(entry);
this.index++;
}
if (this.entries.length > 20) {
this.entries.shift();
this.index--;
}
}
/**
* Removes all matching cells with given id from the stack.
*/
removeAllFromCell(cellId) {
// We need to make sure the last entry from history
// doesn't belong to the given cell id that we need
// to remove from the entries list.
let cellIdCount = 0;
for (let i = 0; i <= this.index; i++) {
const entry = this.get(i);
if (entry.cellId === cellId) cellIdCount++;
}
this.entries = this.entries.filter((entry) => entry.cellId !== cellId);
this.index = this.index - cellIdCount;
if (this.index === -1 && this.entries.length > 0) this.index = 0;
}
/**
* Checks if the current stack is available to navigate back.
*/
canGoBack() {
return this.canGetFromHistory(-1);
}
/**
* Navigates back in the current stack.
*
* If the navigation succeeds, it will return the entry from current index.
* Otherwise, returns null;
*/
goBack() {
return this.getFromHistory(-1);
}
/**
* Checks if the current stack is available to navigate forward.
*/
canGoForward() {
return this.canGetFromHistory(1);
}
/**
* Navigates forward in the current stack.
*
* If the navigation succeeds, it will return the entry from current index.
* Otherwise, returns null;
*/
goForward() {
return this.getFromHistory(1);
}
/**
* Gets the entry in the current stack.
*
* If the stack have at least one entry, it will return the entry from current index.
* Otherwise, returns null;
*/
getCurrent() {
return this.get(this.index);
}
/** @private **/
getEntries() {
return this.entries;
}
/** @private **/
get(index) {
if (this.entries.length <= 0) return null;
return this.entries[index];
}
/** @private **/
getFromHistory(direction) {
if (!this.canGetFromHistory(direction)) return null;
this.index = Math.max(0, this.index + direction);
return this.entries[this.index];
}
/** @private **/
canGetFromHistory(direction) {
if (this.entries.length === 0) return false;
const index = this.index + direction;
return 0 <= index && index < this.entries.length;
}
/** @private **/
isSameCell(cellId) {
const lastEntry = this.get(this.index);
return lastEntry !== null && cellId === lastEntry.cellId;
}
}

View file

@ -0,0 +1,96 @@
import CursorHistory from "../../../js/hooks/session/cursor_history";
test("goBack returns when there's at least one cell", () => {
const cursorHistory = new CursorHistory();
const entry = { cellId: "c1", line: 1, offset: 1 };
expect(cursorHistory.canGoBack()).toBe(false);
expect(cursorHistory.getCurrent()).toStrictEqual(null);
cursorHistory.push(entry.cellId, entry.line, entry.offset);
expect(cursorHistory.canGoBack()).toBe(false);
expect(cursorHistory.getCurrent()).toStrictEqual(entry);
});
describe("push", () => {
test("does not add duplicated cells", () => {
const cursorHistory = new CursorHistory();
const entry = { cellId: "c1", line: 1, offset: 1 };
expect(cursorHistory.canGoBack()).toBe(false);
cursorHistory.push(entry.cellId, entry.line, entry.offset);
cursorHistory.push(entry.cellId, entry.line, 2);
expect(cursorHistory.canGoBack()).toBe(false);
cursorHistory.push(entry.cellId, 2, entry.offset);
expect(cursorHistory.canGoBack()).toBe(false);
});
test("removes oldest entries and keep it with a maximum of 20 entries", () => {
const cursorHistory = new CursorHistory();
for (let i = 0; i <= 19; i++) {
const value = i + 1;
cursorHistory.push(`123${value}`, value, 1);
}
const firstEntry = cursorHistory.get(0);
cursorHistory.push("231", 1, 1);
// Navigates to the bottom of the stack
for (let i = 0; i <= 18; i++) {
cursorHistory.goBack();
}
cursorHistory
.getEntries()
.forEach((entry) => expect(entry).not.toStrictEqual(firstEntry));
});
test("rewrites the subsequent cells if go back and saves a new cell", () => {
const cursorHistory = new CursorHistory();
expect(cursorHistory.canGoBack()).toBe(false);
cursorHistory.push("c1", 1, 1);
cursorHistory.push("c2", 1, 1);
cursorHistory.push("c3", 2, 1);
expect(cursorHistory.canGoBack()).toBe(true);
// Go back to cell id c2
cursorHistory.goBack();
expect(cursorHistory.canGoBack()).toBe(true);
// Go back to cell id c1
cursorHistory.goBack();
expect(cursorHistory.canGoBack()).toBe(false);
// Removes the subsequent cells from stack
// and adds this cell to the stack
cursorHistory.push("c4", 1, 1);
expect(cursorHistory.canGoForward()).toBe(false);
});
});
test("removeAllFromCell removes the cells with given id", () => {
const cursorHistory = new CursorHistory();
const cellId = "123456789";
cursorHistory.push("123", 1, 1);
cursorHistory.push("456", 1, 1);
cursorHistory.push(cellId, 1, 1);
cursorHistory.push("1234", 1, 1);
cursorHistory.push(cellId, 1, 1);
cursorHistory.push("8901", 1, 1);
cursorHistory.push(cellId, 1, 1);
cursorHistory.removeAllFromCell(cellId);
expect(cursorHistory.canGoForward()).toBe(false);
expect(cursorHistory.getCurrent()).toStrictEqual({
cellId: "8901",
line: 1,
offset: 1,
});
});

View file

@ -82,8 +82,8 @@ defmodule LivebookWeb.SessionLive.ShortcutsComponent do
],
navigation_mode: [
%{seq: ["?"], desc: "Open this help modal", basic: true},
%{seq: ["j"], desc: "Focus next cell", basic: true},
%{seq: ["k"], desc: "Focus previous cell", basic: true},
%{seq: ["j"], desc: "Focus cell below", basic: true},
%{seq: ["k"], desc: "Focus cell above", basic: true},
%{seq: ["J"], desc: "Move cell down"},
%{seq: ["K"], desc: "Move cell up"},
%{seq: ["i"], desc: "Switch to insert mode", basic: true},
@ -143,6 +143,18 @@ defmodule LivebookWeb.SessionLive.ShortcutsComponent do
press_all: true,
desc: "Save notebook",
basic: true
},
%{
seq: ["ctrl", "alt", "-"],
seq_mac: ["", "", "-"],
press_all: true,
desc: "Go back to previous editor"
},
%{
seq: ["ctrl", "alt", "="],
seq_mac: ["", "", "="],
press_all: true,
desc: "Go forward to next editor"
}
]
}