Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
13 changes: 13 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ repository = "https://github.com/nteract/desktop"

[workspace.dependencies]
anyhow = "1"
loro_fractional_index = "=1.6.0"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["serde", "v4", "v5"] }
Expand Down
42 changes: 30 additions & 12 deletions apps/notebook/src/hooks/useAutomergeNotebook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {
cellSnapshotsToNotebookCells,
} from "../lib/materialize-cells";
import {
getNotebookCellsSnapshot,
replaceNotebookCells,
resetNotebookCells,
updateNotebookCells,
Expand Down Expand Up @@ -297,20 +296,11 @@ export function useAutomergeNotebook() {
}
: { cell_type: "markdown", id: cellId, source: "", metadata: {} };

// Compute insertion index from the latest external-store snapshot.
const current = getNotebookCellsSnapshot();
let idx: number;
if (!afterCellId) {
idx = 0;
} else {
const afterIdx = current.findIndex((c) => c.id === afterCellId);
idx = afterIdx === -1 ? 0 : afterIdx + 1;
}

// Mutate the WASM doc first — this is the source of truth.
// Uses fractional indexing: afterCellId=null inserts at start.
const handle = handleRef.current;
if (handle) {
handle.add_cell(idx, cellId, cellType);
handle.add_cell_after(cellId, cellType, afterCellId ?? null);
syncToRelay(handle);
}

Expand All @@ -331,6 +321,33 @@ export function useAutomergeNotebook() {
[syncToRelay],
);

const moveCell = useCallback(
(cellId: string, afterCellId?: string | null) => {
const handle = handleRef.current;
if (handle) {
handle.move_cell(cellId, afterCellId ?? null);
syncToRelay(handle);
}

// Optimistic store update: remove cell from old position, insert after target.
updateNotebookCells((prev) => {
const cellIdx = prev.findIndex((c) => c.id === cellId);
if (cellIdx === -1) return prev;
const cell = prev[cellIdx];
const without = prev.filter((c) => c.id !== cellId);
if (!afterCellId) return [cell, ...without];
const targetIdx = without.findIndex((c) => c.id === afterCellId);
if (targetIdx === -1) return [cell, ...without];
const next = [...without];
next.splice(targetIdx + 1, 0, cell);
return next;
});

setDirty(true);
},
[syncToRelay],
);

const deleteCell = useCallback(
(cellId: string) => {
// Guard: never delete the last cell.
Expand Down Expand Up @@ -466,6 +483,7 @@ export function useAutomergeNotebook() {
updateCellSource,
clearCellOutputs,
addCell,
moveCell,
deleteCell,
save,
openNotebook,
Expand Down
3 changes: 3 additions & 0 deletions apps/notebook/src/lib/__tests__/materialize-cells.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ function codeSnapshot(
return {
id,
cell_type: "code",
position: "80",
source,
execution_count: executionCount,
outputs,
Expand All @@ -75,6 +76,7 @@ function markdownSnapshot(id: string, source: string): CellSnapshot {
return {
id,
cell_type: "markdown",
position: "80",
source,
execution_count: "null",
outputs: [],
Expand All @@ -86,6 +88,7 @@ function rawSnapshot(id: string, source: string): CellSnapshot {
return {
id,
cell_type: "raw",
position: "80",
source,
execution_count: "null",
outputs: [],
Expand Down
1 change: 1 addition & 0 deletions apps/notebook/src/lib/materialize-cells.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export {
export interface CellSnapshot {
id: string;
cell_type: string;
position: string; // Fractional index hex string for ordering (e.g., "80", "7F80")
source: string;
execution_count: string; // "5" or "null"
outputs: string[]; // JSON-encoded Jupyter outputs or manifest hashes
Expand Down
33 changes: 32 additions & 1 deletion apps/notebook/src/wasm/runtimed-wasm/runtimed_wasm.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ export class JsCell {
private constructor();
free(): void;
[Symbol.dispose](): void;
/**
* Index in the sorted cell list (for backward compatibility).
*/
readonly index: number;
readonly cell_type: string;
readonly execution_count: string;
Expand All @@ -20,6 +23,10 @@ export class JsCell {
* Get outputs as a JSON array string.
*/
readonly outputs_json: string;
/**
* Fractional index hex string for ordering (e.g., "80", "7F80").
*/
readonly position: string;
readonly source: string;
}

Expand All @@ -35,9 +42,20 @@ export class NotebookHandle {
free(): void;
[Symbol.dispose](): void;
/**
* Add a new cell at the given index.
* Add a new cell at the given index (backward-compatible API).
*
* Internally converts the index to an after_cell_id for fractional indexing.
*/
add_cell(index: number, cell_id: string, cell_type: string): void;
/**
* Add a new cell after the specified cell (semantic API).
*
* - `after_cell_id = None` → insert at the beginning
* - `after_cell_id = Some(id)` → insert after that cell
*
* Returns the position string of the new cell.
*/
add_cell_after(cell_id: string, cell_type: string, after_cell_id?: string | null): string;
/**
* Add a Conda dependency, deduplicating by package name (case-insensitive).
* Initializes the Conda section with ["conda-forge"] channels if absent.
Expand Down Expand Up @@ -114,6 +132,16 @@ export class NotebookHandle {
* Load a notebook document from saved bytes (e.g., from get_automerge_doc_bytes).
*/
static load(bytes: Uint8Array): NotebookHandle;
/**
* Move a cell to a new position (after the specified cell).
*
* - `after_cell_id = None` → move to the beginning
* - `after_cell_id = Some(id)` → move after that cell
*
* This only updates the cell's position field — no delete/re-insert.
* Returns the new position string.
*/
move_cell(cell_id: string, after_cell_id?: string | null): string;
/**
* Create a new empty notebook document.
*/
Expand Down Expand Up @@ -176,6 +204,7 @@ export interface InitOutput {
readonly __wbg_get_jscell_index: (a: number) => number;
readonly jscell_id: (a: number, b: number) => void;
readonly jscell_cell_type: (a: number, b: number) => void;
readonly jscell_position: (a: number, b: number) => void;
readonly jscell_source: (a: number, b: number) => void;
readonly jscell_execution_count: (a: number, b: number) => void;
readonly jscell_outputs_json: (a: number, b: number) => void;
Expand All @@ -188,6 +217,8 @@ export interface InitOutput {
readonly notebookhandle_get_cells_json: (a: number, b: number) => void;
readonly notebookhandle_get_cell: (a: number, b: number, c: number) => number;
readonly notebookhandle_add_cell: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void;
readonly notebookhandle_add_cell_after: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => void;
readonly notebookhandle_move_cell: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
readonly notebookhandle_delete_cell: (a: number, b: number, c: number, d: number) => void;
readonly notebookhandle_update_source: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
readonly notebookhandle_append_source: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
Expand Down
107 changes: 106 additions & 1 deletion apps/notebook/src/wasm/runtimed-wasm/runtimed_wasm.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export class JsCell {
wasm.__wbg_jscell_free(ptr, 0);
}
/**
* Index in the sorted cell list (for backward compatibility).
* @returns {number}
*/
get index() {
Expand Down Expand Up @@ -125,6 +126,26 @@ export class JsCell {
wasm.__wbindgen_export2(deferred1_0, deferred1_1, 1);
}
}
/**
* Fractional index hex string for ordering (e.g., "80", "7F80").
* @returns {string}
*/
get position() {
let deferred1_0;
let deferred1_1;
try {
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
wasm.jscell_position(retptr, this.__wbg_ptr);
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
deferred1_0 = r0;
deferred1_1 = r1;
return getStringFromWasm0(r0, r1);
} finally {
wasm.__wbindgen_add_to_stack_pointer(16);
wasm.__wbindgen_export2(deferred1_0, deferred1_1, 1);
}
}
/**
* @returns {string}
*/
Expand Down Expand Up @@ -174,7 +195,9 @@ export class NotebookHandle {
wasm.__wbg_notebookhandle_free(ptr, 0);
}
/**
* Add a new cell at the given index.
* Add a new cell at the given index (backward-compatible API).
*
* Internally converts the index to an after_cell_id for fractional indexing.
* @param {number} index
* @param {string} cell_id
* @param {string} cell_type
Expand All @@ -196,6 +219,48 @@ export class NotebookHandle {
wasm.__wbindgen_add_to_stack_pointer(16);
}
}
/**
* Add a new cell after the specified cell (semantic API).
*
* - `after_cell_id = None` → insert at the beginning
* - `after_cell_id = Some(id)` → insert after that cell
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSDoc here uses Rust Option terminology (None / Some(id)), but the actual JS API uses null (or omitted) vs string (see the @param {string | null} type). Consider updating the bullets to after_cell_id = null / after_cell_id = "id" to avoid confusing JS/TS consumers.

Suggested change
* - `after_cell_id = None` insert at the beginning
* - `after_cell_id = Some(id)` insert after that cell
* - `after_cell_id = null` insert at the beginning
* - `after_cell_id = "id"` insert after that cell

Copilot uses AI. Check for mistakes.
*
* Returns the position string of the new cell.
* @param {string} cell_id
* @param {string} cell_type
* @param {string | null} [after_cell_id]
* @returns {string}
*/
add_cell_after(cell_id, cell_type, after_cell_id) {
let deferred5_0;
let deferred5_1;
try {
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
const ptr0 = passStringToWasm0(cell_id, wasm.__wbindgen_export3, wasm.__wbindgen_export4);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passStringToWasm0(cell_type, wasm.__wbindgen_export3, wasm.__wbindgen_export4);
const len1 = WASM_VECTOR_LEN;
var ptr2 = isLikeNone(after_cell_id) ? 0 : passStringToWasm0(after_cell_id, wasm.__wbindgen_export3, wasm.__wbindgen_export4);
var len2 = WASM_VECTOR_LEN;
wasm.notebookhandle_add_cell_after(retptr, this.__wbg_ptr, ptr0, len0, ptr1, len1, ptr2, len2);
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true);
var r3 = getDataViewMemory0().getInt32(retptr + 4 * 3, true);
var ptr4 = r0;
var len4 = r1;
if (r3) {
ptr4 = 0; len4 = 0;
throw takeObject(r2);
}
deferred5_0 = ptr4;
deferred5_1 = len4;
return getStringFromWasm0(ptr4, len4);
} finally {
wasm.__wbindgen_add_to_stack_pointer(16);
wasm.__wbindgen_export2(deferred5_0, deferred5_1, 1);
}
}
/**
* Add a Conda dependency, deduplicating by package name (case-insensitive).
* Initializes the Conda section with ["conda-forge"] channels if absent.
Expand Down Expand Up @@ -494,6 +559,46 @@ export class NotebookHandle {
wasm.__wbindgen_add_to_stack_pointer(16);
}
}
/**
* Move a cell to a new position (after the specified cell).
*
* - `after_cell_id = None` → move to the beginning
* - `after_cell_id = Some(id)` → move after that cell
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same doc issue: this JSDoc describes after_cell_id as None / Some(id), but callers pass null/string in JS. Updating the wording will keep the JS-facing docs accurate.

Suggested change
* - `after_cell_id = None` move to the beginning
* - `after_cell_id = Some(id)` move after that cell
* - `after_cell_id === null` move to the beginning
* - `after_cell_id` is a cell id string move after that cell

Copilot uses AI. Check for mistakes.
*
* This only updates the cell's position field — no delete/re-insert.
* Returns the new position string.
* @param {string} cell_id
* @param {string | null} [after_cell_id]
* @returns {string}
*/
move_cell(cell_id, after_cell_id) {
let deferred4_0;
let deferred4_1;
try {
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
const ptr0 = passStringToWasm0(cell_id, wasm.__wbindgen_export3, wasm.__wbindgen_export4);
const len0 = WASM_VECTOR_LEN;
var ptr1 = isLikeNone(after_cell_id) ? 0 : passStringToWasm0(after_cell_id, wasm.__wbindgen_export3, wasm.__wbindgen_export4);
var len1 = WASM_VECTOR_LEN;
wasm.notebookhandle_move_cell(retptr, this.__wbg_ptr, ptr0, len0, ptr1, len1);
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true);
var r3 = getDataViewMemory0().getInt32(retptr + 4 * 3, true);
var ptr3 = r0;
var len3 = r1;
if (r3) {
ptr3 = 0; len3 = 0;
throw takeObject(r2);
}
deferred4_0 = ptr3;
deferred4_1 = len3;
return getStringFromWasm0(ptr3, len3);
} finally {
wasm.__wbindgen_add_to_stack_pointer(16);
wasm.__wbindgen_export2(deferred4_0, deferred4_1, 1);
}
}
/**
* Create a new empty notebook document.
* @param {string} notebook_id
Expand Down
Binary file modified apps/notebook/src/wasm/runtimed-wasm/runtimed_wasm_bg.wasm
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const __wbg_jscell_free: (a: number, b: number) => void;
export const __wbg_get_jscell_index: (a: number) => number;
export const jscell_id: (a: number, b: number) => void;
export const jscell_cell_type: (a: number, b: number) => void;
export const jscell_position: (a: number, b: number) => void;
export const jscell_source: (a: number, b: number) => void;
export const jscell_execution_count: (a: number, b: number) => void;
export const jscell_outputs_json: (a: number, b: number) => void;
Expand All @@ -18,6 +19,8 @@ export const notebookhandle_get_cells: (a: number, b: number) => void;
export const notebookhandle_get_cells_json: (a: number, b: number) => void;
export const notebookhandle_get_cell: (a: number, b: number, c: number) => number;
export const notebookhandle_add_cell: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void;
export const notebookhandle_add_cell_after: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => void;
export const notebookhandle_move_cell: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
export const notebookhandle_delete_cell: (a: number, b: number, c: number, d: number) => void;
export const notebookhandle_update_source: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
export const notebookhandle_append_source: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
Expand Down
1 change: 1 addition & 0 deletions crates/notebook-doc/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ license = "BSD-3-Clause"

[dependencies]
automerge = "0.7"
loro_fractional_index = { workspace = true }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
log = { version = "0.4", optional = true }
Expand Down
Loading
Loading