From 51ca2419b921b157f53c3d023fc3d395657717d0 Mon Sep 17 00:00:00 2001 From: Helge Klein Date: Tue, 15 Sep 2026 21:48:16 +0200 Subject: [PATCH] Text editor: bottom buffer & keyboard viewport scrolling; Docker image size reduction+reporting --- .github/scripts/report_docker_image_size.py | 152 ++++++++++++++++++ .../docker-image-preview-publish.yml | 9 ++ .../workflows/docker-image-security-scan.yml | 7 + .github/workflows/docker-image-validate.yml | 9 ++ Dockerfile | 4 +- TODO.md | 4 - VERSION | 2 +- companion/package-lock.json | 4 +- companion/package.json | 2 +- companion/src-tauri/Cargo.lock | 2 +- companion/src-tauri/Cargo.toml | 2 +- companion/src-tauri/tauri.conf.json | 2 +- frontend/e2e/markdown-viewer.spec.ts | 107 ++++++++++++ frontend/package-lock.json | 4 +- frontend/package.json | 2 +- .../__tests__/SourceTextEditor.test.tsx | 105 ++++++++++++ .../Editor/buildCommonEditorExtensions.ts | 61 ++++++- .../FileBrowser/FileOperationsToolbar.tsx | 2 +- .../src/components/FileBrowser/FileRow.tsx | 1 + .../__tests__/FileOperationsToolbar.test.tsx | 10 ++ .../FileBrowser/__tests__/FileRow.test.tsx | 25 +++ .../src/components/Viewer/MarkdownViewer.tsx | 4 +- frontend/src/components/Viewer/TextViewer.tsx | 6 +- frontend/src/pages/FileBrowser.tsx | 16 +- .../FileBrowser-interactions.test.tsx | 9 ++ .../src/theme/__tests__/viewerStyles.test.ts | 14 +- frontend/src/theme/commonStyles.ts | 8 +- frontend/src/theme/viewerStyles.ts | 6 + scripts/install-system-deps | 1 - .../docs-reports/docs-structure-report.html | 2 +- .../news-and-changes/whats-new/index.md | 3 +- 31 files changed, 544 insertions(+), 41 deletions(-) create mode 100755 .github/scripts/report_docker_image_size.py diff --git a/.github/scripts/report_docker_image_size.py b/.github/scripts/report_docker_image_size.py new file mode 100755 index 00000000..e99d8502 --- /dev/null +++ b/.github/scripts/report_docker_image_size.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""Append local and registry container image size metrics to a CI summary.""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import Any + +BYTES_PER_MEBIBYTE = 1024 * 1024 +DOCKER_IMAGE_INSPECT_COMMAND = ("docker", "image", "inspect") +DOCKER_REGISTRY_MANIFEST_COMMAND = ( + "docker", + "buildx", + "imagetools", + "inspect", + "--raw", +) +SUMMARY_ENVIRONMENT_VARIABLE = "GITHUB_STEP_SUMMARY" + + +def run_command(*command: str) -> str: + """Run a command and return its standard output, with actionable failures.""" + try: + result = subprocess.run(command, check=True, capture_output=True, text=True) + except FileNotFoundError as error: + raise RuntimeError(f"Required command is unavailable: {command[0]}") from error + except subprocess.CalledProcessError as error: + details = error.stderr.strip() or error.stdout.strip() or "no command output" + raise RuntimeError( + f"Command failed ({' '.join(command)}): {details}" + ) from error + return result.stdout.strip() + + +def read_local_image_size(image: str) -> int: + """Return Docker's unpacked image size in bytes.""" + output = run_command(*DOCKER_IMAGE_INSPECT_COMMAND, "--format", "{{.Size}}", image) + try: + size = int(output) + except ValueError as error: + raise RuntimeError( + f"Docker returned an invalid image size for {image!r}: {output!r}" + ) from error + if size < 0: + raise RuntimeError( + f"Docker returned a negative image size for {image!r}: {size}" + ) + return size + + +def read_registry_layer_size(image: str) -> int: + """Return the sum of compressed OCI layer sizes for a platform image manifest.""" + try: + manifest: dict[str, Any] = json.loads( + run_command(*DOCKER_REGISTRY_MANIFEST_COMMAND, image) + ) + except json.JSONDecodeError as error: + raise RuntimeError( + f"Registry returned invalid manifest JSON for {image!r}" + ) from error + + layers = manifest.get("layers") + if not isinstance(layers, list): + raise TypeError( + f"Registry manifest for {image!r} does not contain platform layers" + ) + + total_size = 0 + for layer in layers: + try: + layer_size = layer["size"] + except (KeyError, TypeError) as error: + raise RuntimeError( + f"Registry manifest for {image!r} has invalid layer size data" + ) from error + if ( + not isinstance(layer_size, int) + or isinstance(layer_size, bool) + or layer_size < 0 + ): + raise RuntimeError( + f"Registry manifest for {image!r} has invalid layer size data" + ) + total_size += layer_size + return total_size + + +def format_size(size_in_bytes: int) -> str: + """Format a byte count for the workflow summary without losing precision.""" + return f"{size_in_bytes / BYTES_PER_MEBIBYTE:.2f} MiB ({size_in_bytes:,} bytes)" + + +def write_summary( + platform: str, image: str, local_size: int, registry_size: int | None +) -> None: + """Print and append the size report, when running in GitHub Actions.""" + rows = [("Unpacked image (Docker)", format_size(local_size))] + if registry_size is not None: + rows.append(("Compressed OCI layers (registry)", format_size(registry_size))) + + lines = [ + "## Container Image Size", + "", + f"Platform: `{platform}`", + "", + "| Metric | Size |", + "| --- | ---: |", + *(f"| {label} | {value} |" for label, value in rows), + "", + f"Image: `{image}`", + "", + ] + report = "\n".join(lines) + print(report) + + summary_path = os.environ.get(SUMMARY_ENVIRONMENT_VARIABLE) + if summary_path: + with Path(summary_path).open("a", encoding="utf-8") as summary_file: + summary_file.write(report) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--image", required=True, help="Local Docker image reference") + parser.add_argument("--platform", required=True, help="OCI platform identifier") + parser.add_argument( + "--registry-image", + help="Pushed platform-manifest reference used to report compressed OCI layer size", + ) + arguments = parser.parse_args() + + try: + local_size = read_local_image_size(arguments.image) + registry_size = ( + read_registry_layer_size(arguments.registry_image) + if arguments.registry_image + else None + ) + write_summary(arguments.platform, arguments.image, local_size, registry_size) + except (RuntimeError, TypeError) as error: + print(f"Unable to report container image size: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/docker-image-preview-publish.yml b/.github/workflows/docker-image-preview-publish.yml index ab973557..373c9548 100644 --- a/.github/workflows/docker-image-preview-publish.yml +++ b/.github/workflows/docker-image-preview-publish.yml @@ -306,6 +306,15 @@ jobs: shell: bash run: docker pull --platform ${{ matrix.docker_platform }} "${{ needs.prepare.outputs.staging_image_name }}@${{ steps.build.outputs.digest }}" + - name: Report preview platform image size + shell: bash + run: | + image_ref="${{ needs.prepare.outputs.staging_image_name }}@${{ steps.build.outputs.digest }}" + python3 .github/scripts/report_docker_image_size.py \ + --image "$image_ref" \ + --platform "${{ matrix.docker_platform }}" \ + --registry-image "$image_ref" + - name: Start preview platform validation container shell: bash run: | diff --git a/.github/workflows/docker-image-security-scan.yml b/.github/workflows/docker-image-security-scan.yml index 26cd71ae..c2dc251b 100644 --- a/.github/workflows/docker-image-security-scan.yml +++ b/.github/workflows/docker-image-security-scan.yml @@ -77,6 +77,13 @@ jobs: cache-from: type=gha,scope=docker-image-security-scan-${{ matrix.platform_id }} cache-to: type=gha,mode=max,scope=docker-image-security-scan-${{ matrix.platform_id }} + - name: Report security-scan image size + shell: bash + run: | + python3 .github/scripts/report_docker_image_size.py \ + --image "sambee:security-scan-${{ matrix.platform_id }}" \ + --platform "${{ matrix.docker_platform }}" + - name: Resolve Trivy container image id: trivy-image shell: bash diff --git a/.github/workflows/docker-image-validate.yml b/.github/workflows/docker-image-validate.yml index f71a1b95..022159f0 100644 --- a/.github/workflows/docker-image-validate.yml +++ b/.github/workflows/docker-image-validate.yml @@ -30,6 +30,7 @@ on: - 'scripts/verify-python-runtime-image.py' - 'scripts/verify-node-runtime.py' - 'scripts/verify-archive-single-worker.py' + - '.github/scripts/report_docker_image_size.py' - 'Dockerfile' - '.dockerignore' - 'VERSION' @@ -62,6 +63,7 @@ on: - 'scripts/verify-python-runtime-image.py' - 'scripts/verify-node-runtime.py' - 'scripts/verify-archive-single-worker.py' + - '.github/scripts/report_docker_image_size.py' - 'Dockerfile' - '.dockerignore' - 'VERSION' @@ -146,6 +148,13 @@ jobs: cache-from: type=gha,scope=docker-image-validate-${{ matrix.platform_id }} cache-to: type=gha,mode=max,scope=docker-image-validate-${{ matrix.platform_id }} + - name: Report validation image size + shell: bash + run: | + python3 .github/scripts/report_docker_image_size.py \ + --image "sambee:validation-${{ matrix.platform_id }}" \ + --platform "${{ matrix.docker_platform }}" + - name: Export attested validation OCI layout uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: diff --git a/Dockerfile b/Dockerfile index 55aa72b8..539e35dc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -183,9 +183,9 @@ USER sambee # Expose port EXPOSE 8000 -# Health check (wget is installed via install-system-deps script) +# Health check uses Python's standard library, so no separate HTTP client is needed. HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD wget -qO- http://localhost:8000/api/health >/dev/null || exit 1 + CMD python -c "from urllib.request import urlopen; urlopen('http://localhost:8000/api/health', timeout=5).close()" || exit 1 # Block deployment before migrations when legacy archive operation state remains. CMD ["sh", "-c", "/app/scripts/preflight-archive-v2-cutover && exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1 --no-proxy-headers"] diff --git a/TODO.md b/TODO.md index b73eba53..461a134c 100644 --- a/TODO.md +++ b/TODO.md @@ -1,9 +1,5 @@ # TODO -## Text/Markdown viewers and editors - -- I cannot scroll beneath the last line to move that further up the screen. That means that longer texts end exactly on the last visible line of the viewport. It would be nice to move the up a bit. - ## Misc. commands - Add a command to open the current file list location in a new browser tab (which keyboard shortcut to assign? Ctrl+(Shift)+Tab is needed by the browser) diff --git a/VERSION b/VERSION index 8955a017..adb7b04c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.26 +1.0.27 diff --git a/companion/package-lock.json b/companion/package-lock.json index 8a608023..637aa0cc 100644 --- a/companion/package-lock.json +++ b/companion/package-lock.json @@ -1,12 +1,12 @@ { "name": "sambee-companion", - "version": "1.0.26", + "version": "1.0.27", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sambee-companion", - "version": "1.0.26", + "version": "1.0.27", "dependencies": { "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-autostart": "^2.5.1", diff --git a/companion/package.json b/companion/package.json index 76e96f94..ec90f878 100644 --- a/companion/package.json +++ b/companion/package.json @@ -1,6 +1,6 @@ { "name": "sambee-companion", - "version": "1.0.26", + "version": "1.0.27", "private": true, "type": "module", "scripts": { diff --git a/companion/src-tauri/Cargo.lock b/companion/src-tauri/Cargo.lock index a8a0079e..8ee7b30d 100644 --- a/companion/src-tauri/Cargo.lock +++ b/companion/src-tauri/Cargo.lock @@ -4093,7 +4093,7 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "sambee-companion" -version = "1.0.26" +version = "1.0.27" dependencies = [ "axum", "base64 0.22.1", diff --git a/companion/src-tauri/Cargo.toml b/companion/src-tauri/Cargo.toml index ba925774..f8163ac0 100644 --- a/companion/src-tauri/Cargo.toml +++ b/companion/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sambee-companion" -version = "1.0.26" +version = "1.0.27" edition = "2021" description = "Sambee Companion — opens files from Sambee in native desktop editors" authors = [] diff --git a/companion/src-tauri/tauri.conf.json b/companion/src-tauri/tauri.conf.json index 183f7af3..bdd4e141 100644 --- a/companion/src-tauri/tauri.conf.json +++ b/companion/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2-schema/refs/heads/main/tauri.conf.json.schema", "productName": "Sambee Companion", "identifier": "app.sambee.companion", - "version": "1.0.26", + "version": "1.0.27", "build": { "beforeDevCommand": "npm run dev", "devUrl": "http://localhost:1420", diff --git a/frontend/e2e/markdown-viewer.spec.ts b/frontend/e2e/markdown-viewer.spec.ts index 8437f093..1e79dfac 100644 --- a/frontend/e2e/markdown-viewer.spec.ts +++ b/frontend/e2e/markdown-viewer.spec.ts @@ -10,6 +10,7 @@ const WRAPPED_SELECTION_MARKDOWN = [ "but potentially valuable to other teams as well.", ].join(""); const LONG_UNWRAPPED_LINE = "long unwrapped editor content ".repeat(200); +const CURSOR_EDGE_TOLERANCE_PX = 2; const SCROLLED_SELECTION_MARKDOWN = [ ...Array.from({ length: 120 }, (_, index) => `Filler line ${index + 1}`), WRAPPED_SELECTION_MARKDOWN, @@ -228,6 +229,48 @@ async function activateCellEditor(cellLocator: Locator): Promise { return nestedEditor; } +async function getActiveCursorViewportPosition(editorRoot: Locator): Promise<{ position: "bottom" | "other" | "top" } | null> { + return editorRoot.evaluate((root, edgeTolerance) => { + const activeLine = root.querySelector(".cm-activeLine"); + const scroller = root.querySelector(".cm-scroller"); + + if (!(activeLine instanceof HTMLElement) || !(scroller instanceof HTMLElement)) { + return null; + } + + const activeLineRect = activeLine.getBoundingClientRect(); + const scrollerRect = scroller.getBoundingClientRect(); + const activeLineIsFullyVisible = + activeLineRect.top >= scrollerRect.top - edgeTolerance && activeLineRect.bottom <= scrollerRect.bottom + edgeTolerance; + + if (activeLineIsFullyVisible && activeLineRect.top <= scrollerRect.top + activeLineRect.height + edgeTolerance) { + return { position: "top" as const }; + } + + if (activeLineIsFullyVisible && activeLineRect.bottom >= scrollerRect.bottom - activeLineRect.height - edgeTolerance) { + return { position: "bottom" as const }; + } + + return { position: "other" as const }; + }, CURSOR_EDGE_TOLERANCE_PX); +} + +async function getActiveLineScrollState(editorRoot: Locator): Promise<{ activeLineText: string; scrollTop: number } | null> { + return editorRoot.evaluate((root) => { + const activeLine = root.querySelector(".cm-activeLine"); + const scroller = root.querySelector(".cm-scroller"); + + if (!(activeLine instanceof HTMLElement) || !(scroller instanceof HTMLElement)) { + return null; + } + + return { + activeLineText: activeLine.textContent ?? "", + scrollTop: scroller.scrollTop, + }; + }); +} + async function setCaretToCellEnd(cellEditorLocator: Locator): Promise { await cellEditorLocator.evaluate((element) => { if (!(element instanceof HTMLElement)) { @@ -454,6 +497,70 @@ test.describe("markdown editor selection", () => { })).resolves.toBe(true); }); + test("keeps the cursor on a viewport edge while scrolling a wrapped editor with Ctrl+Arrow keys", async ({ page }) => { + await mockMarkdownViewerApi(page, { initialMarkdown: SCROLLED_SELECTION_MARKDOWN }); + + await openMarkdownViewer(page); + await enterMarkdownEditMode(page); + + const editor = page.getByRole("textbox", { name: "Markdown editor" }); + const editorRoot = page.locator(".sambee-markdown-editor .cm-editor"); + const scroller = editorRoot.locator(".cm-scroller"); + await editor.click(); + + await scroller.evaluate((element) => { + element.scrollTop = Math.floor((element.scrollHeight - element.clientHeight) / 2); + element.dispatchEvent(new Event("scroll")); + }); + + const scrollerBox = await scroller.boundingBox(); + + if (!scrollerBox) { + throw new Error("Expected Markdown editor scroller to be visible"); + } + + await page.mouse.click(scrollerBox.x + 80, scrollerBox.y + 8); + const beforeScrollDown = await getActiveLineScrollState(editorRoot); + + if (!beforeScrollDown) { + throw new Error("Expected an active line before scrolling down"); + } + + await page.keyboard.press("Control+ArrowDown"); + await page.keyboard.press("Control+ArrowDown"); + + await expect.poll(() => getActiveLineScrollState(editorRoot)).not.toEqual(beforeScrollDown); + const afterScrollDown = await getActiveLineScrollState(editorRoot); + + expect(afterScrollDown?.scrollTop).toBeGreaterThan(beforeScrollDown.scrollTop); + expect(afterScrollDown?.activeLineText).not.toBe(beforeScrollDown.activeLineText); + + await expect.poll(() => getActiveCursorViewportPosition(editorRoot)).toEqual({ position: "top" }); + + await scroller.evaluate((element) => { + element.scrollTop = Math.floor((element.scrollHeight - element.clientHeight) / 2); + element.dispatchEvent(new Event("scroll")); + }); + + await page.mouse.click(scrollerBox.x + 80, scrollerBox.y + scrollerBox.height - 8); + const beforeScrollUp = await getActiveLineScrollState(editorRoot); + + if (!beforeScrollUp) { + throw new Error("Expected an active line before scrolling up"); + } + + await page.keyboard.press("Control+ArrowUp"); + await page.keyboard.press("Control+ArrowUp"); + + await expect.poll(() => getActiveLineScrollState(editorRoot)).not.toEqual(beforeScrollUp); + const afterScrollUp = await getActiveLineScrollState(editorRoot); + + expect(afterScrollUp?.scrollTop).toBeLessThan(beforeScrollUp.scrollTop); + expect(afterScrollUp?.activeLineText).not.toBe(beforeScrollUp.activeLineText); + + await expect.poll(() => getActiveCursorViewportPosition(editorRoot)).toEqual({ position: "bottom" }); + }); + test("keeps empty editor lines at the normal line height", async ({ page }) => { await mockMarkdownViewerApi(page, { initialMarkdown: "First line\n\nSecond line" }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 345a1327..f07416b6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "sambee-frontend", - "version": "1.0.26", + "version": "1.0.27", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sambee-frontend", - "version": "1.0.26", + "version": "1.0.27", "hasInstallScript": true, "dependencies": { "@codemirror/autocomplete": "^6.20.3", diff --git a/frontend/package.json b/frontend/package.json index 68ab2f33..242e628d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "sambee-frontend", - "version": "1.0.26", + "version": "1.0.27", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/Editor/__tests__/SourceTextEditor.test.tsx b/frontend/src/components/Editor/__tests__/SourceTextEditor.test.tsx index cf1f3bdc..e8623ddc 100644 --- a/frontend/src/components/Editor/__tests__/SourceTextEditor.test.tsx +++ b/frontend/src/components/Editor/__tests__/SourceTextEditor.test.tsx @@ -49,6 +49,111 @@ const TEST_TEXT_THEME: TextEditorThemeOptions = { }; describe("SourceTextEditor", () => { + it("scrolls the viewport with Ctrl+Arrow keys without changing the selection", async () => { + const editorRef = createRef(); + const initialValue = "Editor content"; + + render( + {}} + /> + ); + + const editor = await screen.findByLabelText("Viewport scroll editor"); + const view = editorRef.current?.getView(); + + if (!view) { + throw new Error("Expected editor view to be available"); + } + + Object.defineProperties(view.scrollDOM, { + clientHeight: { configurable: true, value: 100 }, + scrollHeight: { configurable: true, value: 500 }, + scrollTop: { configurable: true, value: 40, writable: true }, + }); + view.dispatch({ selection: EditorSelection.range(2, 10) }); + const selectionBeforeScroll = view.state.selection.main.toJSON(); + + const downEvent = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ctrlKey: true, key: "ArrowDown" }); + editor.dispatchEvent(downEvent); + + expect(downEvent.defaultPrevented).toBe(true); + expect(view.scrollDOM.scrollTop).toBe(40 + view.defaultLineHeight); + expect(view.state.selection.main.toJSON()).toEqual(selectionBeforeScroll); + expect(view.state.doc.toString()).toBe(initialValue); + + view.scrollDOM.scrollTop = 400; + const bottomEvent = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ctrlKey: true, key: "ArrowDown" }); + editor.dispatchEvent(bottomEvent); + + expect(bottomEvent.defaultPrevented).toBe(true); + expect(view.scrollDOM.scrollTop).toBe(400); + + view.scrollDOM.scrollTop = 0; + const topEvent = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ctrlKey: true, key: "ArrowUp" }); + editor.dispatchEvent(topEvent); + + expect(topEvent.defaultPrevented).toBe(true); + expect(view.scrollDOM.scrollTop).toBe(0); + }); + + it("moves an empty cursor by one visual line before scrolling would hide it", async () => { + const editorRef = createRef(); + + render( + {}} + /> + ); + + const editor = await screen.findByLabelText("Edge cursor editor"); + const view = editorRef.current?.getView(); + + if (!view) { + throw new Error("Expected editor view to be available"); + } + + Object.defineProperties(view.scrollDOM, { + clientHeight: { configurable: true, value: 100 }, + scrollHeight: { configurable: true, value: 500 }, + scrollTop: { configurable: true, value: 40, writable: true }, + }); + view.dispatch({ selection: EditorSelection.cursor(2) }); + vi.spyOn(view, "coordsAtPos").mockReturnValue({ bottom: view.defaultLineHeight + 1, left: 0, right: 1, top: 1 }); + vi.spyOn(view, "moveVertically").mockReturnValue(EditorSelection.cursor(12)); + + editor.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ctrlKey: true, key: "ArrowDown" })); + + expect(view.moveVertically).toHaveBeenCalledWith(expect.objectContaining({ head: 2 }), true, view.defaultLineHeight - 1); + expect(view.state.selection.main.head).toBe(12); + expect(view.scrollDOM.scrollTop).toBe(40 + view.defaultLineHeight); + }); + + it("normalizes dispatched multiple selections to the primary selection", async () => { + const editorRef = createRef(); + + render( {}} />); + + const view = editorRef.current?.getView(); + + if (!view) { + throw new Error("Expected editor view to be available"); + } + + view.dispatch({ selection: EditorSelection.create([EditorSelection.cursor(1), EditorSelection.cursor(12)], 1) }); + + expect(view.state.selection.ranges).toHaveLength(1); + expect(view.state.selection.main.head).toBe(12); + }); + it("renders the initial value and reports user edits", async () => { const user = userEvent.setup(); const onChange = vi.fn(); diff --git a/frontend/src/components/Editor/buildCommonEditorExtensions.ts b/frontend/src/components/Editor/buildCommonEditorExtensions.ts index f04fdafd..72220c0c 100644 --- a/frontend/src/components/Editor/buildCommonEditorExtensions.ts +++ b/frontend/src/components/Editor/buildCommonEditorExtensions.ts @@ -2,7 +2,7 @@ import { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete"; import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands"; import { bracketMatching, defaultHighlightStyle, indentOnInput, syntaxHighlighting } from "@codemirror/language"; import { highlightSelectionMatches, search } from "@codemirror/search"; -import { EditorState, type Extension } from "@codemirror/state"; +import type { Extension } from "@codemirror/state"; import { drawSelection, EditorView, highlightActiveLine, keymap } from "@codemirror/view"; import { buildSelectionLayerExtension } from "./buildEditorSelectionLayer"; @@ -12,6 +12,62 @@ interface CommonEditorExtensionsOptions { lineWrapping?: boolean; } +const EDITOR_VIEWPORT_SCROLL_DIRECTION = { + UP: -1, + DOWN: 1, +} as const; + +function scrollEditorViewport( + view: EditorView, + direction: (typeof EDITOR_VIEWPORT_SCROLL_DIRECTION)[keyof typeof EDITOR_VIEWPORT_SCROLL_DIRECTION] +): boolean { + const { scrollDOM } = view; + const previousScrollTop = scrollDOM.scrollTop; + const maximumScrollTop = Math.max(0, scrollDOM.scrollHeight - scrollDOM.clientHeight); + const nextScrollTop = Math.max(0, Math.min(maximumScrollTop, previousScrollTop + view.defaultLineHeight * direction)); + const scrollDelta = nextScrollTop - previousScrollTop; + + const selection = view.state.selection.main; + const cursorCoords = selection.empty ? view.coordsAtPos(selection.head, selection.assoc) : null; + const scrollerRect = scrollDOM.getBoundingClientRect(); + const caretMovement = + cursorCoords && direction === EDITOR_VIEWPORT_SCROLL_DIRECTION.DOWN + ? { + forward: true, + distance: scrollerRect.top + scrollDelta - cursorCoords.top, + } + : cursorCoords + ? { + forward: false, + distance: cursorCoords.bottom - (scrollerRect.bottom + scrollDelta), + } + : null; + + if (scrollDelta !== 0 && caretMovement && caretMovement.distance > 0) { + view.dispatch({ + selection: view.moveVertically(selection, caretMovement.forward, caretMovement.distance), + scrollIntoView: false, + }); + } + + scrollDOM.scrollTop = nextScrollTop; + + return true; +} + +const editorViewportScrollKeymap = [ + { + key: "Ctrl-ArrowUp", + preventDefault: true, + run: (view: EditorView) => scrollEditorViewport(view, EDITOR_VIEWPORT_SCROLL_DIRECTION.UP), + }, + { + key: "Ctrl-ArrowDown", + preventDefault: true, + run: (view: EditorView) => scrollEditorViewport(view, EDITOR_VIEWPORT_SCROLL_DIRECTION.DOWN), + }, +]; + export function buildCommonEditorExtensions({ defaultSyntaxHighlighting = true, highlightSelectionMatches: includeSelectionMatches = true, @@ -21,7 +77,6 @@ export function buildCommonEditorExtensions({ history(), drawSelection(), buildSelectionLayerExtension(), - EditorState.allowMultipleSelections.of(true), closeBrackets(), indentOnInput(), ...(defaultSyntaxHighlighting ? [syntaxHighlighting(defaultHighlightStyle, { fallback: true })] : []), @@ -30,6 +85,6 @@ export function buildCommonEditorExtensions({ ...(includeSelectionMatches ? [highlightSelectionMatches()] : []), highlightActiveLine(), ...(lineWrapping ? [EditorView.lineWrapping] : []), - keymap.of([indentWithTab, ...closeBracketsKeymap, ...defaultKeymap, ...historyKeymap]), + keymap.of([indentWithTab, ...closeBracketsKeymap, ...editorViewportScrollKeymap, ...defaultKeymap, ...historyKeymap]), ]; } diff --git a/frontend/src/components/FileBrowser/FileOperationsToolbar.tsx b/frontend/src/components/FileBrowser/FileOperationsToolbar.tsx index a14d8725..eb63a2ed 100644 --- a/frontend/src/components/FileBrowser/FileOperationsToolbar.tsx +++ b/frontend/src/components/FileBrowser/FileOperationsToolbar.tsx @@ -83,7 +83,7 @@ export function FileOperationsToolbar({ actions, moreLabel }: FileOperationsTool {visibleActions.map((action) => ( diff --git a/frontend/src/components/FileBrowser/FileRow.tsx b/frontend/src/components/FileBrowser/FileRow.tsx index 9d193ac8..3ab0c5aa 100644 --- a/frontend/src/components/FileBrowser/FileRow.tsx +++ b/frontend/src/components/FileBrowser/FileRow.tsx @@ -397,6 +397,7 @@ export const FileRow = React.memo( prev.index === next.index && prev.useCompactLayout === next.useCompactLayout && prev.useTouchSelectionControls === next.useTouchSelectionControls && + prev.fileRowStyles === next.fileRowStyles && prev.isSelected === next.isSelected && prev.isMultiSelected === next.isMultiSelected && prev.file.name === next.file.name && diff --git a/frontend/src/components/FileBrowser/__tests__/FileOperationsToolbar.test.tsx b/frontend/src/components/FileBrowser/__tests__/FileOperationsToolbar.test.tsx index 93d08305..2bb8a6f2 100644 --- a/frontend/src/components/FileBrowser/__tests__/FileOperationsToolbar.test.tsx +++ b/frontend/src/components/FileBrowser/__tests__/FileOperationsToolbar.test.tsx @@ -40,6 +40,16 @@ describe("FileOperationsToolbar", () => { expect(onClick).toHaveBeenCalledOnce(); }); + it("does not clip the first command's focus indicator", () => { + renderToolbar([createAction("new-directory", { label: "New folder" })]); + + const toolbar = screen.getByTestId("file-operations-toolbar"); + expect(getComputedStyle(toolbar).overflow).toBe("visible"); + expect(getComputedStyle(toolbar.parentElement as HTMLElement).height).toBe("44px"); + expect(getComputedStyle(toolbar.parentElement as HTMLElement).paddingLeft).toBe("4px"); + expect(getComputedStyle(screen.getByRole("button", { name: "New folder" })).minHeight).toBe("36px"); + }); + it("does not invoke disabled actions", () => { const onClick = vi.fn(); renderToolbar([createAction("delete", { label: "Delete", enabled: false, onClick })]); diff --git a/frontend/src/components/FileBrowser/__tests__/FileRow.test.tsx b/frontend/src/components/FileBrowser/__tests__/FileRow.test.tsx index 7d42617b..631b7245 100644 --- a/frontend/src/components/FileBrowser/__tests__/FileRow.test.tsx +++ b/frontend/src/components/FileBrowser/__tests__/FileRow.test.tsx @@ -72,6 +72,31 @@ describe("FileRow", () => { expect(props.onClick).toHaveBeenCalledWith(props.file, props.index); }); + it("rerenders a selected row when its styles change", () => { + const props = createDefaultFileRowProps(); + props.isMultiSelected = false; + props.fileRowStyles = { + ...props.fileRowStyles, + buttonSelected: { backgroundColor: "rgb(1, 2, 3)" }, + }; + const { rerender } = render(); + + const rowButton = screen.getByRole("button", { name: /report\.pdf/i }); + expect(rowButton).toHaveStyle({ backgroundColor: "rgb(1, 2, 3)" }); + + rerender( + + ); + + expect(rowButton).toHaveStyle({ backgroundColor: "rgb(4, 5, 6)" }); + }); + it("disables unavailable archive entries and hides their actions", () => { const props = createDefaultFileRowProps(); props.file = { ...props.file, is_readable: false, archive_entry_state: "blocked" }; diff --git a/frontend/src/components/Viewer/MarkdownViewer.tsx b/frontend/src/components/Viewer/MarkdownViewer.tsx index cdd52603..cffef6b1 100644 --- a/frontend/src/components/Viewer/MarkdownViewer.tsx +++ b/frontend/src/components/Viewer/MarkdownViewer.tsx @@ -41,6 +41,7 @@ import { getMarkdownContentStyles, getMarkdownTableSurfaceColors, getViewerColors, + VIEWER_SCROLL_END_PADDING, } from "../../theme/viewerStyles"; import { isApiError } from "../../types"; import { getApiErrorMessage } from "../../utils/apiErrors"; @@ -1854,7 +1855,8 @@ export const MarkdownViewer: React.FC = ({ }, }, "& .sambee-markdown-editor .cm-content": { - py: CODEMIRROR_EDITOR_CONTENT_PADDING, + pt: CODEMIRROR_EDITOR_CONTENT_PADDING, + pb: VIEWER_SCROLL_END_PADDING, }, }} > diff --git a/frontend/src/components/Viewer/TextViewer.tsx b/frontend/src/components/Viewer/TextViewer.tsx index 75cc7308..285a63e3 100644 --- a/frontend/src/components/Viewer/TextViewer.tsx +++ b/frontend/src/components/Viewer/TextViewer.tsx @@ -20,6 +20,7 @@ import { CODEMIRROR_EDITOR_CONTENT_PADDING, CODEMIRROR_EDITOR_HORIZONTAL_INSET_CSS_VARIABLE, getViewerColors, + VIEWER_SCROLL_END_PADDING, } from "../../theme/viewerStyles"; import { getApiErrorMessage } from "../../utils/apiErrors"; import { openExternalUrl } from "../../utils/externalLinks"; @@ -1120,7 +1121,7 @@ export const TextViewer: React.FC = ({ {error} ) : exceedsEditorLimit ? ( - + {t("viewer.text.limitMessage", { currentSizeMb: (contentSizeBytes / (1024 * 1024)).toFixed(1), @@ -1151,7 +1152,8 @@ export const TextViewer: React.FC = ({ }, }, "& .sambee-text-editor .cm-content": { - py: CODEMIRROR_EDITOR_CONTENT_PADDING, + pt: CODEMIRROR_EDITOR_CONTENT_PADDING, + pb: VIEWER_SCROLL_END_PADDING, }, }} > diff --git a/frontend/src/pages/FileBrowser.tsx b/frontend/src/pages/FileBrowser.tsx index 4ebe3231..454c31b2 100644 --- a/frontend/src/pages/FileBrowser.tsx +++ b/frontend/src/pages/FileBrowser.tsx @@ -22,7 +22,7 @@ import { AppBar, Box, Container, Divider, Snackbar, Toolbar, Typography, useMediaQuery, useTheme } from "@mui/material"; import React, { useCallback, useEffect, useLayoutEffect, useMemo, useState, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; -import { useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom"; +import { useLocation, useNavigate, useParams } from "react-router-dom"; import { ArchiveExtractDialog, type ArchiveExtractionScope } from "../components/FileBrowser/ArchiveExtractDialog"; import { ArchiveOperationProgress } from "../components/FileBrowser/ArchiveOperationProgress"; import type { CompactItemAction } from "../components/FileBrowser/CompactItemActionsMenu"; @@ -392,7 +392,7 @@ const Browser: React.FC = () => { const navigate = useNavigate(); const params = useParams<{ targetType: string; targetId: string; "*": string }>(); const location = useLocation(); - const [searchParams] = useSearchParams(); + const searchParams = useMemo(() => new URLSearchParams(location.search), [location.search]); const theme = useTheme(); const { t } = useTranslation(); const initialRecoverySnapshotRef = React.useRef(loadBrowserRecoverySnapshot()); @@ -1380,6 +1380,7 @@ const Browser: React.FC = () => { return; } + setActivePaneId(nextActivePaneId); navigateToBrowseState( { left: leftTarget, @@ -1590,13 +1591,8 @@ const Browser: React.FC = () => { const nextPaneMode: PaneMode = resolvedRoute.right ? "dual" : "single"; const nextActivePaneId: PaneId = resolvedRoute.right ? resolvedRoute.activePaneId : "left"; - if (paneMode !== nextPaneMode) { - setPaneMode(nextPaneMode); - } - - if (activePaneId !== nextActivePaneId) { - setActivePaneId(nextActivePaneId); - } + setPaneMode((currentPaneMode) => (currentPaneMode === nextPaneMode ? currentPaneMode : nextPaneMode)); + setActivePaneId((currentActivePaneId) => (currentActivePaneId === nextActivePaneId ? currentActivePaneId : nextActivePaneId)); setFileBrowserPaneModePreference(nextPaneMode); localStorage.setItem(ACTIVE_PANE_STORAGE_KEY, nextActivePaneId); @@ -1604,7 +1600,7 @@ const Browser: React.FC = () => { if (pendingPaneFocusRef.current !== nextActivePaneId) { pendingPaneFocusRef.current = null; } - }, [activePaneId, leftApplyLocation, loadingConnections, paneMode, resolvedRoute, rightApplyLocation]); + }, [leftApplyLocation, loadingConnections, resolvedRoute, rightApplyLocation]); useEffect(() => { if (pendingPaneFocusRef.current !== activePaneId) { diff --git a/frontend/src/pages/__tests__/FileBrowser-interactions.test.tsx b/frontend/src/pages/__tests__/FileBrowser-interactions.test.tsx index ee77665e..15d4b900 100644 --- a/frontend/src/pages/__tests__/FileBrowser-interactions.test.tsx +++ b/frontend/src/pages/__tests__/FileBrowser-interactions.test.tsx @@ -412,6 +412,15 @@ describe("Browser Component - Interactions", () => { expect(rightPaneList).toHaveFocus(); }); + await user.keyboard("{Tab}"); + + await waitFor(() => { + expect(localStorage.getItem("active-pane")).toBe("left"); + const leftPaneList = container.querySelector('[data-pane-id="left"] [data-testid="file-list-container"]'); + expect(leftPaneList).toBeInstanceOf(HTMLElement); + expect(leftPaneList).toHaveFocus(); + }); + await user.keyboard("{Control>}b{/Control}"); await waitFor(() => { diff --git a/frontend/src/theme/__tests__/viewerStyles.test.ts b/frontend/src/theme/__tests__/viewerStyles.test.ts index d72cea89..b3e689e4 100644 --- a/frontend/src/theme/__tests__/viewerStyles.test.ts +++ b/frontend/src/theme/__tests__/viewerStyles.test.ts @@ -1,6 +1,11 @@ import { createTheme, type Theme } from "@mui/material/styles"; import { describe, expect, it } from "vitest"; -import { getMarkdownCodeSurfaceColors, getMarkdownContentStyles, getViewerToolbarForegroundColors } from "../viewerStyles"; +import { + getMarkdownCodeSurfaceColors, + getMarkdownContentStyles, + getViewerToolbarForegroundColors, + VIEWER_SCROLL_END_PADDING, +} from "../viewerStyles"; function createMuiTheme(mode: "light" | "dark"): Theme { return createTheme({ @@ -74,4 +79,11 @@ describe("viewerStyles markdown regressions", () => { expect(resolveThemeValue(tableStyles.boxShadow, lightTheme)).toBe("inset 0 0 0 1px #d4c4ae"); expect(resolveThemeValue(tableStyles.backgroundColor, lightTheme)).toBe("#fbf9f4"); }); + + it("keeps end padding outside the shrunk Markdown content", () => { + const styles = getMarkdownContentStyles("#1f262b", "#c24400", "#ff5900") as Record; + + expect(styles.flexShrink).toBe(0); + expect(styles.pb).toEqual(VIEWER_SCROLL_END_PADDING); + }); }); diff --git a/frontend/src/theme/commonStyles.ts b/frontend/src/theme/commonStyles.ts index 50a5c8dc..df93bc4b 100644 --- a/frontend/src/theme/commonStyles.ts +++ b/frontend/src/theme/commonStyles.ts @@ -202,8 +202,8 @@ export const toolbarIconButtonSx: SxProps = { }; export const fileOperationsToolbarSx: SxProps = (theme) => ({ - px: 1, - height: 52, + px: 0.5, + height: 44, boxSizing: "border-box", display: "flex", alignItems: "center", @@ -213,7 +213,7 @@ export const fileOperationsToolbarSx: SxProps = (theme) => ({ }); export const fileOperationsToolbarButtonSx: SxProps = { - minHeight: 44, + minHeight: 36, minWidth: 0, px: 1.5, flexShrink: 0, @@ -230,7 +230,7 @@ export const fileOperationsToolbarButtonSx: SxProps = { export const fileOperationsToolbarOverflowButtonSx: SxProps = { minWidth: 44, - minHeight: 44, + minHeight: 36, p: 1, flexShrink: 0, color: (theme) => theme.palette.text.secondary, diff --git a/frontend/src/theme/viewerStyles.ts b/frontend/src/theme/viewerStyles.ts index 71c21e4c..2caa8fc2 100644 --- a/frontend/src/theme/viewerStyles.ts +++ b/frontend/src/theme/viewerStyles.ts @@ -159,6 +159,10 @@ export const MARKDOWN_CODE_BLOCK_ACTIVE_LINE_NUMBER_BG = "rgba(212, 196, 174, 0. export const MARKDOWN_CONTENT_PADDING = { xs: 2, sm: 4 } as const; export const CODEMIRROR_EDITOR_CONTENT_PADDING = { xs: 2, sm: 4 } as const; export const CODEMIRROR_EDITOR_HORIZONTAL_INSET_CSS_VARIABLE = "--sambee-codemirror-editor-horizontal-inset"; +export const VIEWER_SCROLL_END_PADDING = { + xs: "clamp(64px, 18dvh, 192px)", + sm: "clamp(64px, 18dvh, 192px)", +} as const; export const MARKDOWN_TABLE_CELL_PADDING_INLINE = "0.675rem"; export const MARKDOWN_TABLE_CELL_PADDING_BLOCK = "0.8em"; export const MARKDOWN_TABLE_FONT_SIZE = "0.9375em"; @@ -388,7 +392,9 @@ export function getMarkdownContentStyles(viewerText: string, linkColor: string, minWidth: 0, width: "100%", maxWidth: "100%", + flexShrink: 0, p: MARKDOWN_CONTENT_PADDING, + pb: VIEWER_SCROLL_END_PADDING, ...getMarkdownDocumentStyles(viewerText, linkColor, linkHoverColor), }; } diff --git a/scripts/install-system-deps b/scripts/install-system-deps index d686f7c1..8aad8593 100755 --- a/scripts/install-system-deps +++ b/scripts/install-system-deps @@ -32,7 +32,6 @@ SAMBEE_SYSTEM_PACKAGES=( libexif12 ghostscript libgs-common - wget ) MAGICK_VERSIONED_SYSTEM_PACKAGE="imagemagick-7.q16" diff --git a/website-meta/docs-reports/docs-structure-report.html b/website-meta/docs-reports/docs-structure-report.html index b7dea74e..f7fa4bd3 100644 --- a/website-meta/docs-reports/docs-structure-report.html +++ b/website-meta/docs-reports/docs-structure-report.html @@ -268,7 +268,7 @@

Content diff

- +