docker-publish.yml: the llama.cpp tag resolver was the one step in the prepare job still using `curl | sed` without pipefail. The runner's default `bash -e` shell takes sed's exit status, so an unreachable github.com left TAG empty and the step published the mutable `latest`. Both arch legs re-resolve that through fetch_llama_prebuilt.py and Dockerfile.studio resolves it a third time, so a release cut mid-run can put different llama.cpp bundles under one manifest. Capture the redirect first and fail the job when it is missing or does not land on a release tag, matching the three ref resolvers below it. unsloth_nb_strip_colab.py: strip_notebook read, parsed and then unconditionally os.replace'd. The refresh child re-arms finalize after the entrypoint has execed the container command, so JupyterLab is already serving the tree and a save landing in that window was destroyed, after which migrate recorded the cleaned hash and marked the notebook pristine forever. Re-read the hash once the staged copy is complete and drop it when the file moved, the same rule the refresh publish in unsloth_sync_notebooks.sh already follows. unsloth_nb_view.py: ownership for the view teardown accepted any symlink target under DEST, but every link the tool creates points at DEST/nb. A shortcut the user made in the landing dir to their own file elsewhere in the checkout was therefore classified as ours and deleted on the next boot. Key ownership on DEST/nb instead. cellNav.ts: the edit-mode boundary test compared the cursor line against editor.lineCount, both logical, while JupyterLab wraps markdown and raw editors by default (StaticNotebook.defaultEditorConfig). A one-line markdown header renders as several visual rows, so every arrow left the cell and the wrapped rows could not be reached. Ask CodeMirror whether it can still move one visual line (EditorView.moveVertically, compared by coordsAtPos top) and keep the logical test as the fallback for a non-CodeMirror editor. New tests: 12 passed / 8 failed before, 20 passed / 0 failed after.
73 lines
3.3 KiB
Python
73 lines
3.3 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-Present the Unsloth team. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Colab-style arrow navigation must not swallow wrapped-line movement.
|
|
|
|
`cellNav.ts` owns ArrowUp/ArrowDown in the capture phase and jumps to the
|
|
previous/next cell when the cursor sits on the first/last line of the editor.
|
|
That test used `editor.getCursorPosition().line` against `editor.lineCount`,
|
|
both of which are LOGICAL (JupyterLab's CodeMirrorEditor: `get lineCount() {
|
|
return this.doc.lines }`), while JupyterLab wraps markdown and raw cell editors
|
|
by default (`StaticNotebook.defaultEditorConfig` -> `markdown: { lineWrap: true
|
|
}`, `raw: { lineWrap: true }`; the image's `docker/jupyter/overrides.json` only
|
|
sets `autoClosingBrackets`).
|
|
|
|
So for a one-line markdown header -- what every Unsloth notebook opens with --
|
|
`lineCount === 1`, the cursor is on line 0 == lineCount - 1 from every visual
|
|
row, and BOTH arrows leave the cell: the wrapped rows in between cannot be
|
|
reached at all. Measured in Chromium with CodeMirror 6 + EditorView.lineWrapping
|
|
at the notebook's editor width: 1 logical line renders as 7 visual rows and the
|
|
logical test hijacks the arrows on 7 of 7 rows, in both directions. The same
|
|
measurement on an unwrapped code cell shows the visual test agreeing with the
|
|
logical one on every row, so the Colab-style jump is unchanged there.
|
|
|
|
CodeMirror's own answer is `EditorView.moveVertically(range, forward)`, which
|
|
moves "to the next line (including wrapped lines)"; it returns the unchanged
|
|
head only at offset 0 / doc.length, so a move that stays on the same visual row
|
|
(same `coordsAtPos().top`) is the real editor edge.
|
|
|
|
Static source guard: the labextension is only built inside Dockerfile.studio
|
|
(`jlpm install && jlpm build:prod`), so there is no TS test runner in-repo.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
CELL_NAV = REPO_ROOT / "docker" / "jupyter" / "unsloth_labext" / "src" / "cellNav.ts"
|
|
|
|
|
|
@pytest.fixture(scope = "module")
|
|
def source() -> str:
|
|
assert CELL_NAV.is_file(), f"missing {CELL_NAV}"
|
|
return CELL_NAV.read_text(encoding = "utf-8")
|
|
|
|
|
|
def test_the_edit_mode_boundary_test_asks_codemirror_for_a_visual_line(source: str):
|
|
assert "moveVertically" in source, (
|
|
"the edit-mode boundary check must ask CodeMirror whether it can still "
|
|
"move one VISUAL line (EditorView.moveVertically); a logical lineCount "
|
|
"test makes the wrapped rows of a markdown cell unreachable"
|
|
)
|
|
|
|
|
|
def test_the_visual_check_compares_screen_rows(source: str):
|
|
assert "coordsAtPos" in source, (
|
|
"moveVertically clamps to the document edge instead of returning the "
|
|
"same head, so the two positions have to be compared by visual row"
|
|
)
|
|
|
|
|
|
def test_the_logical_line_test_is_only_a_fallback(source: str):
|
|
body = source[source.index("const editing = notebook.mode === 'edit'") :]
|
|
logical = re.search(r"editor\.lineCount - 1", body)
|
|
assert logical, "the non-CodeMirror fallback should still exist"
|
|
visual = re.search(r"moveVertically", body)
|
|
assert visual and visual.start() < logical.start(), (
|
|
"the visual-line test has to run first; the logical one is only for an "
|
|
"editor that is not a CodeMirrorEditor"
|
|
)
|