From 0d8a83b1eeba000f5b94de4c1c041772d69d9f5d Mon Sep 17 00:00:00 2001 From: eeminionn <109454414+eeminionn@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:12:57 -0400 Subject: [PATCH] feat: make mission workspace resizable --- v2/e2e/student.spec.ts | 37 ++++++ v2/src/pages/MissionWorkspace.tsx | 182 ++++++++++++++++++++++++++++-- v2/src/styles.css | 82 +++++++++++++- 3 files changed, 286 insertions(+), 15 deletions(-) diff --git a/v2/e2e/student.spec.ts b/v2/e2e/student.spec.ts index a1d950c..6c44403 100644 --- a/v2/e2e/student.spec.ts +++ b/v2/e2e/student.spec.ts @@ -26,11 +26,48 @@ test("opens an assigned mission in two actions and runs visible tests", async ({ await expect .poll(() => page.evaluate(() => window.__TOMATIN_EDITOR__?.getValue())) .toContain("const preciosEjemplo = [1200, 850]"); + await expect(page.getByRole("button", { name: "Entregar" })).toBeDisabled(); await page.getByRole("button", { name: "Ejecutar" }).click(); await expect( page.getByText("El código corre, pero aún no pasa todos los tests"), ).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole("button", { name: "Entregar" })).toBeEnabled(); +}); + +test("resizes results and keeps long editor lines on one line", async ({ + page, +}) => { + await page.getByRole("link", { name: "Continuar" }).click(); + await expect + .poll(() => + page.evaluate( + () => window.__TOMATIN_EDITOR__?.getRawOptions().wordWrap, + ), + ) + .toBe("off"); + + const editor = page.locator(".code-pane"); + const results = page.locator(".results-pane"); + const resizer = page.getByRole("separator", { + name: "Ajustar ancho de Resultados", + }); + const editorBefore = await editor.boundingBox(); + const resultsBefore = await results.boundingBox(); + const handle = await resizer.boundingBox(); + expect(editorBefore).not.toBeNull(); + expect(resultsBefore).not.toBeNull(); + expect(handle).not.toBeNull(); + + await page.mouse.move(handle!.x + handle!.width / 2, handle!.y + 80); + await page.mouse.down(); + await page.mouse.move(handle!.x - 70, handle!.y + 80); + await page.mouse.up(); + + const editorAfter = await editor.boundingBox(); + const resultsAfter = await results.boundingBox(); + expect(editorAfter!.width).toBeLessThan(editorBefore!.width); + expect(resultsAfter!.width).toBeGreaterThan(resultsBefore!.width); }); test("keeps independent code when changing language", async ({ page }) => { diff --git a/v2/src/pages/MissionWorkspace.tsx b/v2/src/pages/MissionWorkspace.tsx index 5653273..49f2a39 100644 --- a/v2/src/pages/MissionWorkspace.tsx +++ b/v2/src/pages/MissionWorkspace.tsx @@ -5,6 +5,8 @@ import { useMemo, useRef, useState, + type CSSProperties, + type PointerEvent as ReactPointerEvent, } from "react"; import { ArrowLeft, @@ -19,6 +21,7 @@ import { ExternalLink, FileCode2, Github, + GripVertical, History, Lightbulb, LoaderCircle, @@ -62,6 +65,28 @@ type MobilePane = "brief" | "code" | "results"; type BriefTab = "problem" | "hints" | "history" | "solution"; type SaveState = "loading" | "saving" | "synced" | "local" | "error"; +const RESULTS_WIDTH_KEY = "tomatin.v3.workspace-results-width"; +const MIN_RESULTS_WIDTH = 290; +const MIN_EDITOR_WIDTH = 420; +const RESIZER_WIDTH = 7; +const DEFAULT_RESULTS_WIDTH = 320; + +function readResultsWidth() { + const stored = Number(window.localStorage.getItem(RESULTS_WIDTH_KEY)); + return Number.isFinite(stored) && stored >= MIN_RESULTS_WIDTH + ? stored + : DEFAULT_RESULTS_WIDTH; +} + +function executionFingerprint( + missionId: string, + missionVersion: number, + language: Language, + code: string, +) { + return `${missionId}:${missionVersion}:${language}:${code}`; +} + const configureMonaco: BeforeMount = (monaco) => { monaco.editor.defineTheme("tomatin-terminal", { base: "vs-dark", @@ -312,10 +337,15 @@ export function Component() { const [solution, setSolution] = useState(null); const [solutionError, setSolutionError] = useState(""); const [solutionLoading, setSolutionLoading] = useState(false); + const [resultsWidth, setResultsWidth] = useState(readResultsWidth); + const [executedFingerprints, setExecutedFingerprints] = useState>( + new Set(), + ); const saveTimer = useRef(undefined); const lastEditingSignal = useRef(0); const openedActivityKey = useRef(""); const editorRef = useRef[0] | null>(null); + const workbenchRef = useRef(null); const isStaff = profile?.role === "owner" || profile?.role === "mentor"; const canViewSolution = isStaff && !isStudentPreview; @@ -552,6 +582,24 @@ export function Component() { } }, [briefTab, canViewSolution]); + useEffect(() => { + const workbench = workbenchRef.current; + if (!workbench || typeof ResizeObserver === "undefined") return; + const clampCurrentWidth = () => { + const maxWidth = Math.max( + MIN_RESULTS_WIDTH, + workbench.clientWidth - MIN_EDITOR_WIDTH - RESIZER_WIDTH, + ); + setResultsWidth((current) => + Math.min(Math.max(current, MIN_RESULTS_WIDTH), maxWidth), + ); + }; + const observer = new ResizeObserver(clampCurrentWidth); + observer.observe(workbench); + clampCurrentWidth(); + return () => observer.disconnect(); + }, []); + if (!mission) return ; if (!profile || !viewProfile || !snapshot) return null; @@ -559,6 +607,22 @@ export function Component() { const activeProfile = viewProfile; const allowedLanguages = validAssignment?.allowedLanguages ?? [...LANGUAGES]; const currentCode = codeByLanguage[language]; + const currentExecutionFingerprint = executionFingerprint( + activeMission.id, + activeMission.version, + language, + currentCode, + ); + const hasRunCurrentCode = + executedFingerprints.has(currentExecutionFingerprint) || + history.some( + (attempt) => + attempt.kind === "run" && + attempt.missionVersion === activeMission.version && + attempt.language === language && + attempt.code === currentCode, + ); + const submitNeedsRun = Boolean(validAssignment) && !hasRunCurrentCode; const testInputs = Object.fromEntries( activeMission.variants[language].publicTests.map((testCase) => [ testCase.id, @@ -567,7 +631,7 @@ export function Component() { ); async function execute(kind: AttemptKind) { - if (isStudentPreview) return; + if (isStudentPreview || (kind === "submit" && submitNeedsRun)) return; setRunning(kind); setResult(null); setMobilePane("results"); @@ -586,6 +650,13 @@ export function Component() { })), }; setResult(annotatedResult); + if (kind === "run") { + setExecutedFingerprints((current) => { + const next = new Set(current); + next.add(currentExecutionFingerprint); + return next; + }); + } const attempt: Attempt = { id: annotatedResult.id, userId: activeProfile.id, @@ -609,6 +680,46 @@ export function Component() { setRunning(null); } + function clampResultsWidth(nextWidth: number) { + const workbenchWidth = workbenchRef.current?.clientWidth ?? 0; + const maxWidth = Math.max( + MIN_RESULTS_WIDTH, + workbenchWidth - MIN_EDITOR_WIDTH - RESIZER_WIDTH, + ); + return Math.min(Math.max(nextWidth, MIN_RESULTS_WIDTH), maxWidth); + } + + function resizeResults(event: ReactPointerEvent) { + const workbench = workbenchRef.current; + if (!workbench) return; + event.currentTarget.setPointerCapture(event.pointerId); + setResultsWidth( + clampResultsWidth(workbench.getBoundingClientRect().right - event.clientX), + ); + } + + function finishResize(event: ReactPointerEvent) { + const workbench = workbenchRef.current; + const next = workbench + ? clampResultsWidth( + workbench.getBoundingClientRect().right - event.clientX, + ) + : resultsWidth; + setResultsWidth(next); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + window.localStorage.setItem(RESULTS_WIDTH_KEY, String(next)); + } + + function resizeResultsWithKeyboard(direction: -1 | 1) { + setResultsWidth((current) => { + const next = clampResultsWidth(current + direction * 24); + window.localStorage.setItem(RESULTS_WIDTH_KEY, String(next)); + return next; + }); + } + function resetStarter() { setCodeByLanguage((current) => ({ ...current, @@ -1000,7 +1111,16 @@ export function Component() { -
+
+
{allowedLanguages.map((entry) => ( @@ -1083,7 +1203,13 @@ export function Component() { scrollBeyondLastLine: false, smoothScrolling: true, tabSize: language === "python" ? 4 : 2, - wordWrap: "on", + wordWrap: "off", + scrollBeyondLastColumn: 5, + scrollbar: { + horizontal: "auto", + vertical: "auto", + alwaysConsumeMouseWheel: false, + }, stickyScroll: { enabled: false }, }} /> @@ -1115,7 +1241,12 @@ export function Component() {
-
+
-
+ + +
+
+ ); diff --git a/v2/src/styles.css b/v2/src/styles.css index 55591ed..63b6446 100644 --- a/v2/src/styles.css +++ b/v2/src/styles.css @@ -2367,7 +2367,7 @@ fieldset legend { display: grid; height: calc(100% - 68px); min-height: 0; - grid-template-columns: minmax(280px, 32%) minmax(420px, 1fr) minmax(290px, 30%); + grid-template-columns: minmax(280px, 32%) minmax(0, 1fr); } .brief-pane, @@ -2379,8 +2379,7 @@ fieldset legend { background: rgba(5, 12, 8, 0.88); } -.brief-pane, -.code-pane { +.brief-pane { border-right: 1px solid var(--line); } @@ -2389,6 +2388,62 @@ fieldset legend { flex-direction: column; } +.workbench-panes { + position: relative; + display: grid; + min-width: 0; + min-height: 0; + grid-template-columns: + minmax(420px, 1fr) + 7px + minmax(290px, var(--results-panel-width, 360px)); +} + +.workspace-resizer { + position: relative; + z-index: 3; + display: grid; + width: 7px; + min-width: 7px; + height: 100%; + padding: 0; + place-items: center; + border: 0; + border-right: 1px solid var(--line); + border-left: 1px solid var(--line); + color: transparent; + background: rgba(255, 255, 255, 0.018); + cursor: col-resize; + touch-action: none; +} + +.workspace-resizer::before { + position: absolute; + width: 3px; + height: 44px; + border-radius: 2px; + background: var(--line-strong); + content: ""; + transition: + height 150ms ease, + background 150ms ease; +} + +.workspace-resizer svg { + width: 13px; +} + +.workspace-resizer:hover::before, +.workspace-resizer:focus-visible::before { + height: 64px; + background: var(--green); +} + +.workspace-resizer:focus-visible { + outline: 2px solid var(--green); + outline-offset: -2px; +} + .brief-tabs { display: grid; min-height: 42px; @@ -4523,10 +4578,18 @@ fieldset legend { grid-template-columns: minmax(260px, 30%) minmax(400px, 1fr); } + .workbench-panes { + grid-template-columns: minmax(0, 1fr); + } + + .workspace-resizer { + display: none; + } + .results-pane { position: absolute; z-index: 15; - top: calc(var(--topbar) + 68px); + top: 0; right: 0; bottom: 0; width: min(390px, 40vw); @@ -4706,17 +4769,26 @@ fieldset legend { height: calc(100% - 112px); } + .workbench-panes, .brief-pane, .code-pane, .results-pane { position: absolute; inset: 0; - display: flex; width: auto; border: 0; box-shadow: none; } + .workbench-panes { + display: block; + } + + .brief-pane, + .results-pane { + display: flex; + } + .code-pane { display: grid; }