diff --git a/.claude/skills/add-language/SKILL.md b/.claude/skills/add-language/SKILL.md new file mode 100644 index 0000000..6421d45 --- /dev/null +++ b/.claude/skills/add-language/SKILL.md @@ -0,0 +1,176 @@ +--- +name: add-language +description: Add a new source/target language (lexer, parser, emitter) to Praxly2 and wire it through the Universal AST, translator, and editor UI. Use when adding or removing a language, adding a target the translator can emit, or debugging why a newly added language does not appear in the pickers. +--- + +# Adding a language to Praxly2 + +Every language in Praxly2 is three files plus a handful of registrations. The +compiler work is the easy part to get right; the registrations are where people +lose time, because missing one fails silently in a different layer. + +Read [`specs/`](../../../specs/) for the language you are adding **before** +writing the lexer — it is the authority on what the language accepts, and it +must be updated in the same change if you alter behaviour. +[`docs/ADDING_A_LANGUAGE.md`](../../../docs/ADDING_A_LANGUAGE.md) has longer +code examples; this skill is the integration path. + +## The one invariant + +**Parsers may only produce node types already defined in `src/language/ast.ts`.** +There is no such thing as a language-specific AST node. If your language has a +construct the Universal AST can't express, either desugar it into existing nodes +in the parser, or add the node to the AST and update _every_ emitter, +`interpreter.ts`, `visitor.ts`, and `translator.ts` — see "Adding an AST node" +at the bottom. + +## 1. Write the language module + +``` +src/language// + lexer.ts class Lexer { tokenize(): Token[] } + parser.ts class Parser { parse(): Program } + emitter.ts class Emitter extends ASTVisitor +``` + +`src/language/java/` is the most complete reference. Blocks +(`src/language/blocks/`) is deliberately _not_ a model to copy — its source is +Blockly workspace JSON, so it uses `toAst.ts`/`fromAst.ts` instead of a +lexer/parser/emitter and bypasses the emitter dispatch entirely. + +**Lexer** + +- Import `Token` from `'../lexer'`. +- Terminate the stream with `{ type: 'EOF', value: '', start: this.pos }`. +- Match multi-character operators (`==`, `!=`, `<=`, `>=`) **before** their + single-character prefixes, or `==` lexes as two `=`. +- Skip whitespace and comments without emitting tokens. Comments that must + survive into translations are handled by `src/language/comments.ts`, not here. +- Python's lexer is the odd one out: it converts indentation into virtual `{}` + and `;` tokens so the parser can treat it as brace-delimited. Copy that + approach only if your language is indentation-sensitive. + +**Parser** + +- Import `generateId` from `'../ast'`. **Every node needs `id: generateId()`** — + the debugger and source maps are keyed on it, and a missing id silently + disables line highlighting for that construct. +- Recursive descent, lowest precedence at the top of the call tree: + `assignment → logicalOr → logicalAnd → equality → comparison → term → factor +→ unary → postfix → primary`. +- Normalise operators to Universal AST spelling: logical operators become + `'and'`/`'or'`/`'not'`; not-equal becomes `'!='`. Emitters translate back out. +- Use `synchronize()` to skip to the next statement after a parse error. + +**Emitter** + +- Extend `ASTVisitor` from `'../visitor'` and implement all 21 abstract + `visit*` methods (`tsc` will list any you miss — that is the checklist). +- Build output with `this.emit(line, nodeId?)`, `this.indent()`, `this.dedent()`. +- Pass the node id to `emit()` for any statement you want to be debuggable; that + is what populates the `SourceMap` the debugger highlights from. +- Use `this.escapeString(value, quote?)` for string literals so backslashes, + quotes, and newlines re-parse instead of breaking the output across lines. +- Guard expression precedence with the `Precedence` constants from `visitor.ts`. + +## 2. Register the language (6 places) + +Miss one and the failure looks unrelated, so work down the list. + +| # | File | Change | Symptom if missed | +| --- | ----------------------------------------- | --------------------------------------------------------- | -------------------------------------------------------------- | +| 1 | `src/language/visitor.ts` | add to the `TargetLanguage` union | `tsc` error in `translator.ts` | +| 2 | `src/language/translator.ts` | import the emitter + add a `case` in `translateWithMap()` | runtime `Unsupported target language` | +| 3 | `src/components/LanguageSelector.tsx` | add to `SupportedLang` **and** `LANG_LABELS` | `tsc` error (`LANG_LABELS` is an exhaustive `Record`) | +| 4 | `src/components/editor/SourcePane.tsx` | add to `SOURCE_OPTIONS` | language can't be picked as a _source_ | +| 5 | `src/components/editor/AddPanelStrip.tsx` | add to `PANEL_LANGS` | no icon in the left rail, can't open as a _translation panel_ | +| 6 | `src/utils/editorUtils.ts` | add a `case` to `getCodeMirrorExtensions()` | no syntax highlighting (falls through to `[]`, which is valid) | + +`LANG_LABELS` being a `Record` is deliberate: adding to +the union without adding a label is a compile error, which is the one +registration you cannot forget. + +Then wire parsing: + +**`src/hooks/useCodeParsing.ts`** — add the import and a `case` in `parseCode()`: + +```typescript +case '': + tokens = new Lexer(input).tokenize(); + return new Parser(tokens).parse(); +``` + +`getTranslation()` in the same hook routes through `Translator` and needs no +change once step 2 is done. + +Two allow-lists also gate embed links, and are easy to overlook because they are +plain string arrays rather than typed unions: + +- `VALID_TARGET_LANGS` in `src/hooks/useEmbedLinkImport.ts` — `?targetLang=` on + an editor link +- `VALID_TO_LANGS` in `src/pages/EmbedPage.tsx` — `?to=` on an embed link + +## 3. Syntax highlighting (optional) + +CodeMirror ships grammars for Java, Python, and JavaScript. CSP and Praxis use +Lezer grammars instead: + +- `src/language//.grammar` — compiled to `.grammar.js` by the Vite + plugin (`resolve.extensions` in `vite.config.js` lists `.grammar`) +- `src/language//lezer.ts` — wraps the compiled grammar as an extension +- return it from `getCodeMirrorExtensions()` + +Returning `[]` is fine while you get the compiler working; the pane renders as +plain text. + +## 4. Demo program and tests + +- Add `examples/demo.` exercising every node your parser can produce, and + register it in `src/utils/demoPrograms.ts` (`DEMO_PROGRAMS`). See + `examples/README.md` for the coverage policy. `tests/round-trip.test.ts` picks + it up and translates it to every target, asserting output equivalence — this + is the strongest signal that your emitter is faithful. +- Add `tests/.test.ts`. See the **add-tests** skill. +- Optionally add entries to `EXAMPLE_PROGRAMS` in `src/utils/sampleCodes.ts` + (note its `ExampleLanguage` type is narrower than `SupportedLang` — text + languages only). + +## Adding an AST node + +Only if the Universal AST genuinely can't express the construct. Update, in +order, and let `tsc` drive you: + +1. `src/language/ast.ts` — the node type +2. `src/language/visitor.ts` — `abstract visitX()` + the dispatch `case` +3. every emitter under `src/language/*/emitter.ts` (5 of them) +4. `src/language/interpreter.ts` — execution +5. `src/language/translator.ts` — recurse into the body in `analyzeBlock`, or + type inference silently skips anything nested inside your node +6. `src/language/blocks/fromAst.ts` + `toAst.ts` if it should be representable + as a block + +## Verify + +```bash +npx tsc --noEmit # exhaustive-switch errors are your checklist +npm run test:run # unit + round-trip +npm run test-browser # Selenium csv/ suite — needs Chrome; not part of test:run +``` + +Do **not** edit `csv/praxly.test.csv`; it is the original regression corpus. + +## Checklist + +- [ ] `lexer.ts` — operators longest-match-first, ends with `EOF` +- [ ] `parser.ts` — correct precedence, `generateId()` on every node +- [ ] `emitter.ts` — all 21 `visit*` implemented, `emit(line, nodeId)` for statements +- [ ] `TargetLanguage` + `translator.ts` case +- [ ] `SupportedLang` + `LANG_LABELS` +- [ ] `SOURCE_OPTIONS` + `PANEL_LANGS` +- [ ] `getCodeMirrorExtensions()` case +- [ ] `useCodeParsing.ts` case +- [ ] `VALID_TARGET_LANGS` + `VALID_TO_LANGS` if embed links should carry it +- [ ] `examples/demo.` + `DEMO_PROGRAMS` +- [ ] `tests/.test.ts` +- [ ] `specs/.md` written or updated +- [ ] `tsc`, `test:run`, `test-browser` all green diff --git a/.claude/skills/add-tests/SKILL.md b/.claude/skills/add-tests/SKILL.md new file mode 100644 index 0000000..3807633 --- /dev/null +++ b/.claude/skills/add-tests/SKILL.md @@ -0,0 +1,187 @@ +--- +name: add-tests +description: Write Vitest tests for Praxly2 language features, hooks, and stores — lexer, parser, interpreter, translator, round-trip, and debugger layers. Use when adding a language feature, fixing a parser/interpreter/emitter bug, adding a UI hook, or deciding which of the existing test files a new case belongs in. +--- + +# Testing Praxly2 + +Unit tests live in `tests/` and run under Vitest with **no DOM environment** — +they exercise the compiler pipeline and plain TypeScript modules directly. + +```bash +npm run test:run # single run +npm run test # watch mode +npm run test-browser # Selenium csv/ suite — separate, needs Chrome +``` + +## Hard rule + +**Never edit `csv/praxly.test.csv`.** It is the original regression corpus, run +in a real browser by `npm run test-browser` and _not_ by `npm run test:run`. +After any parser, interpreter, or emitter change, run the browser suite too — +`test:run` passing is not sufficient evidence for those layers. + +## Where a test goes + +| File | Holds | +| ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `tests/.test.ts` | Everything specific to one language: lexer, parser, interpreter, and that language's emitter output. One per language (`python`, `java`, `javascript`, `csp`, `praxis`). | +| `tests/round-trip.test.ts` | Cross-language fidelity: translates each `examples/demo.*` to every target, re-parses, re-runs, and compares output. | +| `tests/examples.test.ts` | Guards that every `examples/demo.*` still parses, runs without error, and translates to all targets without throwing. | +| `tests/control-flow.test.ts`, `blank-lines.test.ts`, `comments.test.ts`, `blocks.test.ts`, `debugger.test.ts`, `placeholder.test.ts` | Cross-cutting behaviour that isn't owned by one language. | +| `tests/bugfixes.test.ts` | Regression cases for specific fixed bugs. Add a comment naming the original failure. | +| `tests/chatStore.test.ts` | Non-compiler modules (zustand stores, plain utils). | + +A new _language feature_ goes in `tests/.test.ts`. A new _AST node_ or +interpreter behaviour usually needs a case in the relevant language file **and** +a demo update so `round-trip.test.ts` covers it in every target. + +## The four APIs you will assert against + +Get these right — they are the most common source of broken test scaffolding. + +```typescript +// 1. Lexer → Token[] +const tokens = new CSPLexer('x <- 5').tokenize(); + +// 2. Parser → Program (Universal AST) +const program = new CSPParser(tokens).parse(); + +// 3. Interpreter → string[] ← an ARRAY OF LINES, not a string, not { output } +const lines = new Interpreter().interpret(program, source); + +// 4. Translator → string +const code = new Translator().translate(program, 'java'); +``` + +`interpret()` takes the original source as its second argument (used for error +locations) and returns one entry per output line. `DISPLAY`/`print` without a +newline buffers into the current line, so two statements can land in a single +array entry — assert with `toEqual([...])` on the whole array when spacing +matters. + +## Patterns + +**Lexer** — assert on token presence, not position, so unrelated token changes +don't break the test: + +```typescript +it('should tokenize assignment operator', () => { + const tokens = new CSPLexer('x <- 5').tokenize(); + expect(tokens).toContainEqual(expect.objectContaining({ type: 'OPERATOR', value: '<-' })); +}); + +it('should skip comments', () => { + const tokens = new CSPLexer('x <- 5 // comment\ny <- 10').tokenize(); + const identifiers = tokens.filter((t) => t.type === 'IDENTIFIER').map((t) => t.value); + expect(identifiers).toEqual(['x', 'y']); +}); +``` + +**Parser** — `toMatchObject` on the shape, so you assert the fields you care +about and ignore `id` (which is regenerated every parse): + +```typescript +it('parses REPEAT UNTIL as a negated While', () => { + const program = new CSPParser( + new CSPLexer('REPEAT UNTIL(x > 5) { x <- x + 1 }').tokenize() + ).parse(); + expect(program.body[0]).toMatchObject({ + type: 'While', + condition: { type: 'UnaryExpression', operator: 'not' }, + }); +}); +``` + +**Interpreter** — assert the line array. Note that CSP's `DISPLAY` appends a +space rather than a newline, so three iterations land in **one** array entry: + +```typescript +it('runs REPEAT n TIMES', () => { + const source = 'i <- 0\nREPEAT 3 TIMES\n{\n i <- i + 1\n DISPLAY(i)\n}'; + const program = new CSPParser(new CSPLexer(source).tokenize()).parse(); + expect(new Interpreter().interpret(program, source)).toEqual(['1 2 3 ']); +}); +``` + +Write the source in the syntax the language actually has — check `specs/.md` +first. A construct the parser doesn't recognise usually yields an **empty +program**, so the test "passes" against `[]` while proving nothing. If an +interpreter assertion comes back empty, suspect the syntax before the +interpreter. + +**Translator** — check for the meaningful snippet, never whole-program equality +(formatting is not a stable contract): + +```typescript +it('translates if/else to Java', () => { + const source = 'IF (x > 0) { DISPLAY("pos") } ELSE { DISPLAY("neg") }'; + const program = new CSPParser(new CSPLexer(source).tokenize()).parse(); + const java = new Translator().translate(program, 'java'); + expect(java).toContain('if (x > 0)'); + expect(java).toContain('else'); +}); +``` + +**Round-trip** — the strongest assertion available, because every language runs +on the same interpreter: translate, re-parse, re-run, compare output. Prefer +this over eyeballing emitted text when you change an emitter. + +```typescript +it('CSP → Python preserves behaviour', () => { + const source = 'x <- 5\nDISPLAY(x)'; + const program = new CSPParser(new CSPLexer(source).tokenize()).parse(); + const python = new Translator().translate(program, 'python'); + const reparsed = new PythonParser(new PythonLexer(python).tokenize()).parse(); + expect(new Interpreter().interpret(reparsed, python)).toEqual( + new Interpreter().interpret(program, source) + ); +}); +``` + +**Debugger** — drive `Debugger` directly; `step()` returns a `DebugStep` with +`variables`, `callStack`, cumulative `output`, and `isComplete`. See the `parse` +helper at the top of `tests/debugger.test.ts` for the established shape. + +**Hooks and components** — there is no DOM environment configured, so test the +extracted logic rather than rendering. The refactor deliberately put page +behaviour in hooks and pure helpers for this reason; the pure ones are directly +testable with no setup: + +- `src/utils/panelLayout.ts` — `toggleStack`, `reorderPanel`, `swapStacked`, `inSameColumn` +- `src/utils/languageSwitch.ts` — `planLanguageSwitch` returns a + `'empty' | 'translated' | 'untranslatable'` plan +- `src/utils/aiPanelContext.ts` — `buildAiPanelContext` +- `src/utils/debugHandlers.ts` — `computeMultiplePanelHighlighting` + +`tests/chatStore.test.ts` shows the pattern for a module that needs +`localStorage`: stub it on `globalThis` before importing the store. If you ever +do need to render a component, add `environment: 'jsdom'` and `@testing-library/react` +first — neither is currently a dependency. + +## What to cover, by change type + +**New statement node** — lexer tokens; parser node shape; interpreter execution +(including the loop/branch boundary); each emitter's output; then add it to the +language's `examples/demo.*` so round-trip covers all five targets. + +**New expression type** — tokenisation; expression-tree shape including +precedence against neighbouring operators; evaluated value; one emitter's +output, with parenthesisation checked. + +**New built-in** — parser recognises it as a `CallExpression`; interpreter +returns the right value (and mutates in place where the spec says so); each +emitter maps it to the target's equivalent. Cross-language differences belong in +`specs/stdlib.md`. + +**Bug fix** — add the failing input to `tests/bugfixes.test.ts` with a one-line +comment naming what used to break. + +## Conventions + +- `describe` per layer (`'CSP Lexer'`, `'CSP Parser'`, …), `it` per case. +- Small and focused beats one big integration test — a failure should name the + layer that broke. +- Cover the edge cases the parser actually branches on: empty body, nested + loops, chained calls, trailing `else if`. +- Never assert on `id` values; they are freshly generated per parse. diff --git a/.claude/skills/add-ui-feature/SKILL.md b/.claude/skills/add-ui-feature/SKILL.md new file mode 100644 index 0000000..11e8679 --- /dev/null +++ b/.claude/skills/add-ui-feature/SKILL.md @@ -0,0 +1,190 @@ +--- +name: add-ui-feature +description: Add or change UI in the Praxly2 editor, embed player, or account page — panes, toolbar controls, side panels, dialogs, keyboard shortcuts, layout and resize behaviour, and example programs. Use when deciding which hook or component owns a piece of state, or where a new control belongs. +--- + +# Adding a UI feature to Praxly2 + +The three pages are **composition only**. Behaviour lives in hooks; layout lives +in components. Before adding state to a page, find the hook that already owns +that concern — nearly always there is one. + +## Where things live + +``` +src/pages/ + EditorPage.tsx ~390 lines — wires hooks to panes, owns only code/sourceLang/toggles + EmbedPage.tsx ~165 lines — layout switch (?to= present or not) + AccountPage.tsx ~ 70 lines — nav + section switch + +src/hooks/ ← behaviour + useCodeParsing source text → AST, AST → translation (+ source map) + useCodeDebugger step-through state machine + useProgramRunner plain (non-debug) run that can pause on input() and resume + useEditorExecution editor: parse-on-type, run, debug, console, pane highlighting + useEmbedExecution embed: the same, minus panels + useEditorLayout pane widths/heights, the width budget, every resize drag + useTranslationPanels which panels are open, columns/stacking, drag-and-drop + useMemDiaPanes per-pane memory-diagram height + open/closed + useEditorSession localStorage persistence (code, lang, open panels, toggles) + useEditorMenus header dropdowns + click-outside + useEditorShortcuts F5 / Shift+F5 / F10 + useAiSelection highlight-to-chat selection + button coords + useOpenCodeBridge "Open in editor" from an AI chat code block + useEmbedLinkImport ?code= / ?targetLang= on an editor link + useEmbedData ?code= (v2) and #code= (v1) on an embed link + usePanelSourceMaps refresh panel source maps when the AST changes + useHighlightedEditor CodeMirror view ref + debug line highlighting + useTextSize Settings → text size + useDragWidth single-divider width drag (embed) + useAccountData account profile/usage/chats + status banner + +src/components/ + editor/ SourcePane, TranslationPaneItem, TranslationPanelGrid, AddPanelStrip, + AiSidePanel, EditorHeader, EditorDialogs, AskAiButton, MemDia, + BlocklyPane(+Lazy), layoutConstants.ts, types.ts + embed/ EmbedSourcePane, EmbedTranslationPane, EmbedOutput, EmbedActions, + StdinPrompt, EmbedErrorScreen + account/ AccountHeader, AccountNav, SignedOutCard, StatusBanner, Avatar, + HomeSection, PersonalSection, SecuritySection, AiSettingsSection, + ProfileSection, DataSection, UsageCard, ChatHistoryCard, styles.ts + ai/ ChatThread, HistoryPanel, Markdown, ApiKeyGate, byok.ts + OutputPanel, HighlightableCodeMirror, JSONTree, VariableFrames, + ConfirmModal, ResizeHandle, LanguageLogo, LanguageSelector + +src/utils/ panelLayout, languageSwitch, aiPanelContext, debugHandlers, + editorUtils, codemirrorConfig, embedCodec, sampleCodes, demoPrograms +``` + +Despite its name, `LanguageSelector.tsx` exports **no component** — only the +`SupportedLang` type and `LANG_LABELS`. The live language pickers are +`SOURCE_OPTIONS` in `SourcePane.tsx` and `PANEL_LANGS` in `AddPanelStrip.tsx`. + +## State rules + +- **No global store for editor state.** Zustand (`src/store/appStore.ts`) is used + only for chat sessions, AI prefs, BYOK settings, and the editor bridge — not + for the program, panels, or layout. +- **Behaviour goes in a hook, not the page.** If you are about to add a + `useState` to `EditorPage.tsx`, first check whether an existing hook owns that + concern. The page holds only `code`, `sourceLang`, the two panel toggles, the + pending-dialog flags, and `embedCopied`. +- **Pure transforms go in `src/utils/`**, not in a hook — that is what makes + them testable without a DOM (`panelLayout.ts` is the model). +- Hooks return an object; the page spreads it into panes as props. + +### Which hook owns what + +| You want to… | Touch | +| ------------------------------------------------------ | -------------------------------------------------------------------- | +| change how code runs or what the console shows | `useEditorExecution` / `useEmbedExecution` | +| change run-with-`input()` behaviour in **both** pages | `useProgramRunner` | +| change pane sizing, resize limits, or the width budget | `useEditorLayout` + `components/editor/layoutConstants.ts` | +| add/remove/rearrange translation panels | `useTranslationPanels` (+ `utils/panelLayout.ts` for the pure rules) | +| change what survives a reload | `useEditorSession` | +| add a keyboard shortcut | `useEditorShortcuts` | +| add a header dropdown | `useEditorMenus` + `EditorHeader` | + +## Common tasks + +**Toolbar button** — render it in `EditorHeader.tsx`, pass a handler prop from +`EditorPage`. If it mutates program state, the handler should call into +`useEditorExecution` (`run`, `debugStart`, `clearConsole`, `stopSession`) rather +than reimplementing the reset. + +**Keyboard shortcut** — add it to `useEditorShortcuts`. It reads handlers through +a ref so the `document` listener registers once and never goes stale; follow that +pattern instead of adding a second listener. + +**New side panel** — model it on `AiSidePanel`: render it conditionally in +`EditorPage`'s `
`, add a toggle to `AddPanelStrip`, and if it takes +horizontal space, add its width to the reserved budget in `useEditorLayout` +(`getContentAvailableWidth` / `getMaxSourceWidth`) so the pane row still fills +exactly. + +**Confirmation dialog** — add the pending state to `EditorPage`, render through +`EditorDialogs.tsx`, and use `ConfirmModal`. All three existing dialogs follow +the same "pending value is non-null while the dialog is open" shape. + +**New pane content** — `TranslationPaneItem` renders three ways (`ast` → `JSONTree`, +`blocks` → `BlocklyPaneLazy`, otherwise `HighlightableCodeMirror`). Extend that +switch; `TranslationPanelGrid` handles columns, stacking, and the elastic last +column and should not need changes. + +**Layout / resize** — all pixel budgets are in +`components/editor/layoutConstants.ts`. The invariant to preserve: the row's +width is `viewport − ADD_STRIP_WIDTH − (AI panel if open)`, split between the +source pane and root panels, with the **last column elastic** so a drag can never +expose bare background. + +**Example program** — add to `EXAMPLE_PROGRAMS` in `src/utils/sampleCodes.ts`: + +```typescript +{ + id: string; // unique kebab-case + title: string; + description: string; + category: 'fundamentals' | 'conditionals' | 'loops' | 'functions'; + lang: ExampleLanguage; // text languages only — no 'blocks'/'ast' + code: string; +} +``` + +Per-language _demos_ are different: they come from `examples/demo.*` via +`src/utils/demoPrograms.ts` and are covered by the round-trip tests. + +**CodeMirror behaviour** — add a `StateField`/`Extension` in +`src/utils/codemirrorConfig.ts`, then include it where the pane builds its +extensions (`sourceExtensions` in `EditorPage`, `HighlightableCodeMirror` for +read-only panes). Debug highlighting already works via `highlightedLinesField` + +`dispatchLineHighlighting`; reuse `useHighlightedEditor` rather than wiring a new +view ref. + +**Adding a language to the UI** — see the **add-language** skill; the UI half is +`SupportedLang` + `LANG_LABELS`, `SOURCE_OPTIONS`, `PANEL_LANGS`, and +`getCodeMirrorExtensions()`. + +## Styling + +- Tailwind utility classes only — no CSS modules, no styled-components, no + `style={{}}` except for computed geometry (pane widths, drag offsets, absolute + positions). +- Dark theme: `bg-slate-950` page, `slate-900` chrome, `indigo-*` accents, + `emerald-*` for memory diagrams, `red-*` for errors. +- Copy the focus-ring constant used in neighbouring components + (`focus-visible:ring-2 focus-visible:ring-indigo-400 …`) rather than inventing + one. +- Editor text size comes from the `--praxly-font-size` CSS variable + (`useTextSize`); Blockly panes take the numeric px value as a prop. +- No emojis unless explicitly asked. + +## Accessibility + +The existing panes set these consistently — match them: +`aria-label` on icon-only buttons, `aria-expanded`/`aria-haspopup` + +`role="listbox"`/`role="option"` on dropdowns, `aria-pressed` on toggles, +`aria-live="polite"` on output, and `aria-hidden` on decorative icons and resize +handles. + +## Verify + +```bash +npx tsc --noEmit # strict, with noUnusedLocals/noUnusedParameters +npm run test:run +npm run dev # http://localhost:5173/v2/editor +``` + +There is no DOM test environment, so UI changes are verified in a browser — use +the **verify** skill to drive Chrome via Selenium. Extract anything worth +asserting into `src/utils/` so it can be unit-tested without rendering. + +## Checklist + +- [ ] Behaviour landed in a hook (or an existing one), not inline in the page +- [ ] Pure logic extracted to `src/utils/` where it could be unit-tested +- [ ] No new global store for editor state +- [ ] Tailwind only; matches the dark theme and neighbouring focus rings +- [ ] ARIA attributes match the surrounding components +- [ ] If it consumes width: added to the `useEditorLayout` budget +- [ ] `npx tsc --noEmit` clean +- [ ] Driven in a real browser diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md new file mode 100644 index 0000000..058d64b --- /dev/null +++ b/.claude/skills/verify/SKILL.md @@ -0,0 +1,64 @@ +--- +name: verify +description: Build, launch, and drive Praxly2 in a real browser to verify changes end-to-end. +--- + +# Verifying Praxly2 changes + +## Launch + +```bash +npm run dev -- --port 5199 # serves http://localhost:5199/v2/ +``` + +The app is a pure-frontend Vite SPA. Keycloak init runs against a remote +server but the app renders regardless (`finally` in `src/main.tsx`); +without a login the AI panel shows a sign-in prompt — that's expected. + +## Drive it (selenium) + +`selenium-webdriver` + `chromedriver` are devDependencies. **chromedriver's +major version must match the installed Chrome major** (`"/Applications/ +Google Chrome.app/Contents/MacOS/Google Chrome" --version`). Run scripts +from the repo root so `require('selenium-webdriver')` resolves. + +Gotchas learned the hard way: + +- Use `/v2/editor?...` for query params. `/v2/` redirects to `/v2/editor` + with `replace` and **drops the query string**. +- Seed the editor via the embed codec instead of typing: + `LZ.compressToEncodedURIComponent(JSON.stringify({ code, lang }))` + (`lz-string` is in node_modules) → `?code=&targetLang=` + opens the editor with that source and a translation panel. +- CodeMirror: click `.cm-content`, then send keys. +- Blockly panes: workspace is `.blocklySvg`; count blocks with + `.blocklySvg g[data-id]`; toolbox rows are `.blocklyToolboxCategory` + (click via a dispatched `pointerdown` — a plain selenium click gets + intercepted by the category row div); flyout blocks are + `.blocklyFlyout g[data-id]`. +- AI panel root is the div with class `z-[140]`; the add-panel strip is + `.add-panel-dropdown`. Both have a `cursor-col-resize` handle child + that resizes the AI panel (drag with Actions API and compare + `getRect().width`). +- Run button: `//button[contains(., 'Run')]`; console output text is + readable from `main`'s text. +- Blockly's own dialogs are custom Praxly modals: `.fixed.z-\[400\]` overlay + with an `input` inside. After creating a variable, Blockly closes the + flyout — re-open the category before counting/dragging its blocks. +- The Settings → text size control is `input[type='range']` inside the + settings dropdown; drive it with arrow keys. Block text size should track + it (theme is rebuilt per size — see the theme-name note in + `src/language/blocks/blockDefs.ts`: Blockly caches injected CSS by theme + name, so a same-name theme swap never updates fonts). + +## Flows worth driving + +1. Load python via embed URL with `targetLang=blocks` → blocks panel + renders; type in the source → block count changes (realtime). +2. Source-language dropdown (`.source-lang-dropdown button`) → switch to + Blocks → toolbox + editable workspace appear; press Run → console + output proves blocks JSON → AST → interpreter. +3. AI panel (bot button `button[title='Open AI Assistant']`): both + resize handles, header icons, auto-created chat (needs auth for the + chat itself). +4. `/v2/account` — dark slate/indigo theme, matches editor. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 659b136..a27a713 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -26,20 +26,48 @@ Each language lives under `src/language//` with exactly 3 files: | `parser.ts` | `Parser` | `parse(): Program` — tokens to Universal AST | | `emitter.ts` | `Emitter extends ASTVisitor` | Visitor that generates target language code | -CSP and Praxis additionally have Lezer grammar files (`.grammar` → auto-compiled `.grammar.js`) and `lezer.ts` for CodeMirror syntax highlighting. Java, Python, and JavaScript use hand-written lexers only. Blocks (`src/language/blocks/`) is the exception to the 3-file pattern — its "source text" is Blockly workspace JSON, so it has `fromAst.ts`/`toAst.ts` (AST ⇄ Blockly conversion), `blockDefs.ts`, `blocklyDialogs.ts`, and `serialization.ts` instead of lexer/parser/emitter. +CSP and Praxis additionally have Lezer grammar files (`.grammar` → auto-compiled `.grammar.js`) and `lezer.ts` for CodeMirror syntax highlighting. Java, Python, and JavaScript use hand-written lexers only. Blocks (`src/language/blocks/`) is the exception to the 3-file pattern — its "source text" is Blockly workspace JSON, so it has `fromAst.ts`/`toAst.ts` (AST ⇄ Blockly conversion), `blockDefs.ts`, `blocklyDialogs.ts`, and `serialization.ts` instead of lexer/parser/emitter, and `Translator.translateWithMap()` special-cases it before the emitter switch. -When adding a new language, follow [docs/ADDING_A_LANGUAGE.md](../docs/ADDING_A_LANGUAGE.md). +When adding a new language, use the [`add-language` skill](../.claude/skills/add-language/SKILL.md) and [docs/ADDING_A_LANGUAGE.md](../docs/ADDING_A_LANGUAGE.md). + +## Frontend architecture + +Three routes, each a thin composition layer over hooks: + +| Page | Route | Owns | +| ----------------------- | ------------- | ------------------------------------------------------------------------- | +| `pages/EditorPage.tsx` | `/v2/editor` | Wires hooks to panes; holds only source text, language, and panel toggles | +| `pages/EmbedPage.tsx` | `/v2/embed` | Layout switch between the simple and `?to=` translation layouts | +| `pages/AccountPage.tsx` | `/v2/account` | Nav + section switch | + +All other paths redirect to `/v2/editor`; there is no separate landing route. + +**Behaviour lives in `src/hooks/`, not in the pages.** The important ones: +`useCodeParsing` (text → AST → translation), `useCodeDebugger` (step-through), +`useProgramRunner` (plain run that pauses on `input()` and resumes — shared by +the editor and the embed player), `useEditorExecution` / `useEmbedExecution` +(per-page orchestration), `useEditorLayout` (pane widths and every resize drag), +`useTranslationPanels` (which panels are open, columns, drag-and-drop), +`useEditorSession` (localStorage persistence). + +**Pure transforms live in `src/utils/`** so they are unit-testable without a DOM: +`panelLayout.ts` (column/stacking rules), `languageSwitch.ts` (can this program +survive a language change?), `aiPanelContext.ts`, `debugHandlers.ts`. + +Panes live under `src/components/{editor,embed,account,ai}/`. For anything +UI-side, use the [`add-ui-feature` skill](../.claude/skills/add-ui-feature/SKILL.md). ## Language specs & demos - [`specs/`](../specs/) is the **authority** for each language's supported syntax, semantics, and standard library — one file per language (`praxis.md`, `csp.md`, `java.md`, - `javascript.md`, `python.md`) plus `stdlib.md` (shared built-ins mapped across all five + `javascript.md`, `python.md`, `blocks.md`) plus `stdlib.md` (shared built-ins mapped across all five languages). Read the relevant spec before changing a language's behavior, and update it in the same change. Do not restate language rules in this file. - [`examples/`](../examples/) holds one runnable demo per language (`demo.`); each exercises every AST node its parser can produce and is round-tripped to every target by - `tests/round-trip.test.ts` (see `examples/README.md`). + `tests/round-trip.test.ts` (see `examples/README.md`). Demos are surfaced in the UI through + `src/utils/demoPrograms.ts`. ## Build & Test @@ -48,27 +76,37 @@ npm run dev # Vite dev server (http://localhost:5173/v2/) npm run build # TypeScript check + Vite production build npm run test:run # Vitest unit tests, single run (`npm run test` = watch mode) npm run test-browser # Selenium csv/ regression suite (requires Chrome) +npx tsc --noEmit # Type-check only ``` - **Base URL**: `/v2/` (configured in `vite.config.js`) -- **Routes**: `/v2/editor` (main IDE), `/v2/embed` (embeddable), `/v2/account` (account page); all other paths redirect to `/v2/editor` — there is no separate landing route +- `npm run test-browser` is **not** part of `test:run`. Run it after any parser, + interpreter, or emitter change — it drives the real UI in Chrome. ## Conventions -- **TypeScript strict mode** — `noUnusedLocals`, `noUnusedParameters` enabled. -- **React 19 + Vite 6** — functional components, hooks only, no class components. -- **State management** — local `useState` per page, no global store. Logic is extracted into `useCodeParsing` and `useCodeDebugger` hooks. -- **Styling** — Tailwind CSS utility classes. Dark theme (slate-950 background). No CSS modules or styled-components. +- **TypeScript strict mode** — `noUnusedLocals`, `noUnusedParameters`, and `verbatimModuleSyntax` enabled; type-only imports must use `import type`. +- **React 19 + Vite 8 + react-router 8** — functional components, hooks only, no class components. Router imports come from `react-router` (not the deprecated `react-router-dom`). +- **State management** — no global store for editor state; page state lives in hooks under `src/hooks/`. Zustand (`src/store/appStore.ts`) is used only for chat sessions, AI preferences, BYOK settings, and the editor bridge. +- **Styling** — Tailwind CSS utility classes. Dark theme (slate-950 background). No CSS modules or styled-components; `style={{}}` only for computed geometry. - **Recursive descent parsing** — all parsers implement grammar rules as methods with operator precedence encoded in the call hierarchy (lowest precedence = highest in call tree). - **Error recovery** — parsers use `synchronize()` to skip to the next valid statement after errors. -- **Source mapping** — every AST node has a unique `id`. Emitters track `nodeId → lineNumber` in `SourceMap` for debugger line highlighting. +- **Source mapping** — every AST node has a unique `id` from `generateId()`. Emitters track `nodeId → lineNumber` in `SourceMap` for debugger line highlighting. - **Python lexer** — converts indentation to virtual `{}`/`;` tokens before parsing, so the parser treats it like a brace-delimited language. +- **Formatting** — Prettier (`npm run prettier:write`); enforced on staged files by lint-staged. ## Testing -- **Unit tests** (`tests/`): One file per language. Test lexer tokenization, parser AST output, and round-trip translation using Vitest `describe`/`it`/`expect`. -- **CSV test matrix** (`csv/praxly.test.csv`): Columns are `Test Name | Program Code | User Input | Expected Output | Expected Error`. Selenium tests run each row in a headless browser. -- When adding language features, add new unit tests only to the corresponding test file. Do not edit the the CSV matrix; those code snippets are from the original test suite. +- **Unit tests** (`tests/`): One file per language, plus `round-trip.test.ts`, `examples.test.ts`, and cross-cutting files (`control-flow`, `comments`, `blank-lines`, `blocks`, `debugger`, `bugfixes`, `placeholder`, `chatStore`). There is **no DOM environment** — test the compiler pipeline and pure modules directly. +- **API shapes worth memorising**: `tokenize(): Token[]`, `parse(): Program`, `interpret(program, source): string[]` (an array of lines, not a string), `translate(program, target): string`. +- **CSV test matrix** (`csv/praxly.test.csv`): Columns are `Test Name | Program Code | User Input | Expected Output | Expected Error`. Selenium runs each row in a headless browser. **Do not edit this file** — those snippets are the original regression suite. +- Details and patterns: the [`add-tests` skill](../.claude/skills/add-tests/SKILL.md). + +## Skills + +Task-specific guides live in [`.claude/skills/`](../.claude/skills/) as Claude +skills (`SKILL.md` + frontmatter), listed in [skills/README.md](skills/README.md): +`add-language`, `add-tests`, `add-ui-feature`, `verify`. ## Troubleshooting diff --git a/.github/skills/add-language.md b/.github/skills/add-language.md deleted file mode 100644 index 4b5e06e..0000000 --- a/.github/skills/add-language.md +++ /dev/null @@ -1,110 +0,0 @@ -# Skill: Add a New Language - -Complete checklist for adding a new source/target language (e.g., JavaScript, C++, Go) to Praxly. - -Read `CLAUDE.md` and `docs/ADDING_A_LANGUAGE.md` before starting — they have code examples. This file is the integration checklist; those files have the implementation detail. - -## 1. Create the language module - -``` -src/language// - lexer.ts — class Lexer { tokenize(): Token[] } - parser.ts — class Parser { parse(): Program } - emitter.ts — class Emitter extends ASTVisitor { ... } -``` - -Copy an existing language as a starting point. `src/language/java/` is the most complete example. - -### Lexer requirements - -- Import `Token` from `'../lexer'` -- End token stream with `{ type: 'EOF', value: '', start: this.pos }` -- Match multi-character operators (`==`, `!=`, `<=`, `>=`) **before** single-character ones -- Skip whitespace and comments (don't emit tokens for them) - -### Parser requirements - -- Import `generateId` from `'../ast'` — every AST node needs `id: generateId()` -- Recursive descent: lower-precedence rules call higher-precedence rules -- Precedence order (low → high): assignment → logicalOr → logicalAnd → equality → comparison → term → factor → unary → postfix → primary -- Normalize operators to Universal AST conventions: logical ops become `'and'`/`'or'`/`'not'`, not-equal becomes `'!='` - -### Emitter requirements - -- Extend `ASTVisitor` from `'../visitor'` -- Implement `visitRepeatUntil` — it's abstract in `ASTVisitor` -- Use `this.emit(line, nodeId?)`, `this.indent()`, `this.dedent()` for output -- `generateExpression(expr, minPrecedence)` — use `Precedence` constants from `visitor.ts` -- Call `this.recordSourceMap(nodeId, lineNumber)` for statements you want debuggable - -## 2. Register in `src/language/visitor.ts` - -Add your language to `TargetLanguage`: - -```typescript -// Before -export type TargetLanguage = 'java' | 'python' | 'csp' | 'praxis' | 'javascript'; -// After -export type TargetLanguage = 'java' | 'python' | 'csp' | 'praxis' | 'javascript' | ''; -``` - -## 3. Register in `src/language/translator.ts` - -```typescript -// Add import -import { Emitter } from './/emitter'; - -// Add case in translateWithMap() -case '': emitter = new Emitter(context); break; -``` - -## 4. Wire into the UI parsing hook - -**`src/hooks/useCodeParsing.ts`** - -```typescript -import { Lexer } from '../language//lexer'; -import { Parser } from '../language//parser'; - -// In parseCode() switch: -case '': - tokens = new Lexer(input).tokenize(); - return new Parser(tokens).parse(); -``` - -## 5. Wire into the UI type system (4 places) - -**`src/components/LanguageSelector.tsx`** — `SupportedLang` union type + add to the `langs` array in the component - -**`src/components/editor/AddPanelStrip.tsx`** — add to `PANEL_LANGS` array - -**`src/utils/editorUtils.ts`** — add a `case '':` to `getCodeMirrorExtensions()` (return `[]` if no CodeMirror extension exists, or install `@codemirror/lang-`) - -**`src/hooks/useCodeParsing.ts`** — also handles `getTranslation()` which already routes through `Translator` — no change needed there beyond the import/case above - -## 6. (Optional) Syntax highlighting - -If a Lezer grammar is needed for CSP/Praxis-style custom highlighting: - -- Create `src/language//.grammar` -- Create `src/language//lezer.ts` that imports the compiled `.grammar.js` -- Return the Lezer extension from `getCodeMirrorExtensions()` - -## 7. Write tests - -See `.github/skills/add-tests.md`. Minimum: lexer tokenization, parser AST shape, round-trip translation to at least one other language. - -## Checklist - -- [ ] `src/language//lexer.ts` — handles all operators, keywords, strings, comments; ends with EOF -- [ ] `src/language//parser.ts` — correct precedence; all nodes use `generateId()` -- [ ] `src/language//emitter.ts` — implements all `abstract visit*` methods from `ASTVisitor` -- [ ] `src/language/visitor.ts` — `TargetLanguage` union updated -- [ ] `src/language/translator.ts` — import + switch case added -- [ ] `src/hooks/useCodeParsing.ts` — import + switch case added -- [ ] `src/components/LanguageSelector.tsx` — `SupportedLang` union + `langs` array updated -- [ ] `src/components/editor/AddPanelStrip.tsx` — `PANEL_LANGS` updated -- [ ] `src/utils/editorUtils.ts` — `getCodeMirrorExtensions()` case added -- [ ] `tests/.test.ts` — lexer, parser, and integration tests written -- [ ] `npx tsc --noEmit` — zero errors -- [ ] `npm test` — all tests pass diff --git a/.github/skills/add-tests.md b/.github/skills/add-tests.md deleted file mode 100644 index 13d202d..0000000 --- a/.github/skills/add-tests.md +++ /dev/null @@ -1,141 +0,0 @@ -# Skill: Add Tests for a New Feature - -Guide for writing Vitest unit tests for new language features. Tests live in `tests/` — one file per language. - -## Rules - -- **Do not edit `csv/praxly.test.csv`** — that is a regression suite from the original codebase; it is run separately via Selenium -- Add tests only to the relevant `tests/.test.ts` file -- Run tests with `npm test` (Vitest) - -## Test file structure - -```typescript -import { describe, it, expect } from 'vitest'; -import { Lexer } from '../src/language//lexer'; -import { Parser } from '../src/language//parser'; -import { Interpreter } from '../src/language/interpreter'; -import { Translator } from '../src/language/translator'; -import { SymbolTable } from '../src/language/visitor'; - -describe(' Lexer', () => { ... }); -describe(' Parser', () => { ... }); -describe(' Interpreter', () => { ... }); -describe(' Translator', () => { ... }); -``` - -## Lexer tests - -Test that tokens are emitted with the correct `type` and `value`. Use `toContainEqual` with `expect.objectContaining` to avoid coupling to token order. - -```typescript -it('should tokenize assignment operator', () => { - const tokens = new CSPLexer('x <- 5').tokenize(); - expect(tokens).toContainEqual(expect.objectContaining({ type: 'OPERATOR', value: '<-' })); -}); - -it('should skip comments', () => { - const tokens = new CSPLexer('x <- 5 // comment\ny <- 10').tokenize(); - const identifiers = tokens.filter((t) => t.type === 'IDENTIFIER').map((t) => t.value); - expect(identifiers).toContain('x'); - expect(identifiers).toContain('y'); - expect(identifiers).not.toContain('comment'); -}); -``` - -## Parser / AST tests - -Test the shape of the generated AST. Use `toMatchObject` for partial matching of the relevant fields. - -```typescript -it('should parse a while loop', () => { - const tokens = new CSPLexer('REPEAT UNTIL(x > 5) { x <- x + 1 }').tokenize(); - const ast = new CSPParser(tokens).parse(); - expect(ast.body[0]).toMatchObject({ - type: 'While', - condition: { type: 'UnaryExpression', operator: 'not' }, - }); -}); -``` - -## Interpreter tests - -Capture stdout via the `Interpreter` and assert on `output`. - -```typescript -it('should execute a basic for loop', () => { - const code = `FOR i FROM 1 TO 3 { DISPLAY(i) }`; - const tokens = new CSPLexer(code).tokenize(); - const ast = new CSPParser(tokens).parse(); - const interp = new Interpreter(); - const result = interp.interpret(ast); - expect(result.output).toBe('1\n2\n3\n'); -}); -``` - -## Translation tests - -Test that AST translates to correct target language code. Check for key snippets, not exact full-program equality (formatting may vary). - -```typescript -it('should translate if/else to Java', () => { - const code = `IF (x > 0) { DISPLAY("pos") } ELSE { DISPLAY("neg") }`; - const tokens = new CSPLexer(code).tokenize(); - const ast = new CSPParser(tokens).parse(); - const translator = new Translator(); - const java = translator.translate(ast, 'java'); - expect(java).toContain('if (x > 0)'); - expect(java).toContain('else'); -}); -``` - -## Round-trip tests - -Verify that code can be translated from one language to another and back without information loss. Parse the output of a translation and check key AST properties. - -```typescript -it('should round-trip CSP → Python → CSP', () => { - const src = `x <- 5\nDISPLAY(x)`; - const tokens = new CSPLexer(src).tokenize(); - const ast = new CSPParser(tokens).parse(); - const translator = new Translator(); - const python = translator.translate(ast, 'python'); - expect(python).toContain('x = 5'); - expect(python).toContain('print'); -}); -``` - -## What to test for each feature type - -### New statement node (e.g., `RepeatUntil`) - -1. Lexer: correct tokens for the new syntax -2. Parser: AST node has correct `type`, `condition`, `body` -3. Interpreter: loop executes body, stops at correct condition -4. Each emitter target: translated output has correct semantics - -### New expression type - -1. Lexer: operators/keywords tokenize correctly -2. Parser: expression tree has correct shape (operator, left, right) -3. Interpreter: expression evaluates to expected value -4. At least one emitter: generates syntactically valid target code - -### New built-in function (e.g., `LENGTH`, `APPEND`) - -1. Parser: recognizes as `CallExpression` with correct callee name -2. Interpreter: returns the right value / mutates correctly -3. Java emitter: maps to the correct Java method call - -### New class feature (constructor, method, field) - -1. Parser: `ClassDeclaration` node has correct `body` members -2. Interpreter: `new Obj()` instantiates, method calls work -3. Java emitter: generates valid Java class syntax - -## Tips - -- Use `describe` blocks to group related tests; use `it` for individual cases -- Prefer small, focused tests over large integration tests -- When a feature has edge cases (empty body, nested loops, chained calls), add a test for each -- If a test is testing a specific bug fix, add a short comment explaining the original failure diff --git a/.github/skills/add-ui-feature.md b/.github/skills/add-ui-feature.md deleted file mode 100644 index a9de187..0000000 --- a/.github/skills/add-ui-feature.md +++ /dev/null @@ -1,106 +0,0 @@ -# Skill: Add a UI Feature - -Guide for adding new UI features to Praxly's editor. Read `docs/COMPONENT_REFERENCE.md` for component API details. - -## Architecture - -``` -src/ - pages/ - EditorPage.tsx ← main IDE page; owns all editor state - EmbedPage.tsx ← embeddable iframe version - AccountPage.tsx ← account page - components/ - editor/ ← editor-specific sub-components - EditorHeader.tsx ← toolbar, run/debug buttons, example picker - SourcePane.tsx ← left source editor panel - TranslationPaneItem.tsx ← right-side translation panels - AddPanelStrip.tsx ← the strip for adding new panels - AiSidePanel.tsx ← AI assistant side panel - types.ts ← shared Panel type - LanguageSelector.tsx ← language dropdown (defines SupportedLang) - CodeEditorPanel.tsx ← CodeMirror wrapper used by all editors - OutputPanel.tsx ← program output display - TranslationPanel.tsx ← individual translated code panel - ResizeHandle.tsx ← draggable resize divider - HighlightableCodeMirror.tsx ← CodeMirror with line highlighting - hooks/ - useCodeParsing.ts ← parses source → AST, generates translations - useCodeDebugger.ts ← step-through debug state machine - utils/ - editorUtils.ts ← CodeMirror extensions per language - sampleCodes.ts ← example programs - embedCodec.ts ← URL encode/decode for share links - codemirrorConfig.ts ← CodeMirror state fields, transactions - debugHandlers.ts ← source map → line highlighting logic -``` - -## State management rules - -- **No global store** — all state lives in `EditorPage.tsx` via `useState` -- **Pass down via props** — child components receive callbacks and state as props -- **Extract logic to hooks** — if state logic is complex or reused, put it in `src/hooks/` -- **React 19 / functional components only** — no class components - -## Adding a new panel type or editor feature - -1. If it needs new state, add `useState` in `EditorPage.tsx` -2. If it adds a new button/control to the top toolbar, edit `EditorHeader.tsx` -3. If it modifies how source code is parsed or translated, update `useCodeParsing.ts` -4. If it adds a new side panel, add it alongside `AiSidePanel.tsx` and wire its toggle in `EditorPage.tsx` -5. If it modifies how translation panels work, edit `TranslationPaneItem.tsx` or `TranslationPanel.tsx` - -## Styling - -- **Tailwind CSS utility classes only** — no CSS modules, no `style={{}}` blocks, no styled-components -- **Dark theme** — `bg-slate-950` background; use `slate-*`, `zinc-*`, `sky-*` for accents -- **No emojis** unless the user explicitly asks -- Match the visual style of existing components; copy patterns from nearby components - -## Adding a new language to the UI - -When adding a new language (frontend/UI side only — see `add-language.md` for the compiler side): - -1. Add to `SupportedLang` union in `src/components/LanguageSelector.tsx` -2. Add to the `langs` array inside `LanguageSelector.tsx` (controls what appears in the dropdown) -3. Add to `PANEL_LANGS` in `src/components/editor/AddPanelStrip.tsx` -4. Add a `case` to `getCodeMirrorExtensions()` in `src/utils/editorUtils.ts` - -## Adding a new example program - -Edit `src/utils/sampleCodes.ts`. Each example has: - -```typescript -{ - id: string; // unique kebab-case id - title: string; - description: string; - category: 'fundamentals' | 'functions' | 'classes' | 'algorithms'; - lang: SupportedLang; // the source language - code: string; // the program text -} -``` - -## Adding a keyboard shortcut or toolbar button - -1. Add the handler in `EditorPage.tsx` or the relevant hook -2. Render the button in `EditorHeader.tsx` -3. Wire `onKeyDown` at the `EditorPage` level for keyboard shortcuts -4. Follow the existing icon/button patterns (` - - - - {/* ADD THIS: */} - - - ); -} +**c. [src/components/editor/AddPanelStrip.tsx](../src/components/editor/AddPanelStrip.tsx)** — +`PANEL_LANGS` controls the left icon rail that opens translation panels: + +```typescript +const PANEL_LANGS: SupportedLang[] = [ + 'ast', + 'blocks', + 'csp', + 'java', + 'javascript', // ADD THIS + 'praxis', + 'python', +]; ``` +Add a case to `LanguageLogo` too, or the rail button renders without an icon. + +**Embed links.** Two plain string arrays gate which languages a shared URL may +name. They aren't typed unions, so nothing warns you: + +- `VALID_TARGET_LANGS` in [src/hooks/useEmbedLinkImport.ts](../src/hooks/useEmbedLinkImport.ts) — `?targetLang=` +- `VALID_TO_LANGS` in [src/pages/EmbedPage.tsx](../src/pages/EmbedPage.tsx) — `?to=` + ### Step 6.3: (Optional) Add CodeMirror Syntax Highlighting For better syntax highlighting, install a CodeMirror extension for your language: @@ -1068,35 +1066,50 @@ For better syntax highlighting, install a CodeMirror extension for your language npm install @codemirror/lang-javascript ``` -Then update [src/utils/editorUtils.ts](../../src/utils/editorUtils.ts): +Then update [src/utils/editorUtils.ts](../src/utils/editorUtils.ts): ```typescript import { javascript } from '@codemirror/lang-javascript'; -export function getCodeMirrorExtensions(lang: SupportedLang) { +export const getCodeMirrorExtensions = (lang: SupportedLang | 'json'): any[] => { + const baseExtensions: any[] = []; + switch (lang) { - case 'python': - return [python()]; case 'java': - return [java()]; - case 'csp': - return []; // No extension available + baseExtensions.push(java()); + break; + case 'python': + baseExtensions.push(python()); + break; case 'praxis': - return []; // Custom grammar + baseExtensions.push(praxis()); // Lezer grammar, see Step 6.4 + break; + case 'csp': + baseExtensions.push(csp()); // Lezer grammar + break; // ADD THIS: case 'javascript': - return [javascript()]; - case 'ast': - return []; + baseExtensions.push(javascript()); + break; + case 'blocks': + // Rendered by BlocklyPane, not CodeMirror — no extensions needed. + break; } -} + + return baseExtensions; +}; ``` +There is no `case 'ast'`: the AST view renders through `JSONTree`, and the +source pane maps `ast → python` before asking for extensions. Falling through +with no case returns `[]`, which is valid — the pane just renders as plain text, +so this step is safe to defer until the compiler side works. + ## Step 7: Testing ### Unit Test Your Lexer -Create [tests/javascript.test.ts](../../tests/javascript.test.ts): +Create [tests/javascript.test.ts](../tests/javascript.test.ts): ```typescript import { describe, it, expect } from 'vitest'; @@ -1385,12 +1398,13 @@ Before considering your language "complete," ensure: - [ ] Generates executable code - [ ] **Integration** - - [ ] Added to `TargetLanguage` type in `translator.ts` - - [ ] Imported and registered in `translator.ts` switch - - [ ] Added to `useCodeParsing()` hook with lexer/parser imports - - [ ] Added to `SupportedLang` type in `EditorPage.tsx` - - [ ] Added to language dropdown in UI - - [ ] Added to "Add Panel" menu + - [ ] Added to `TargetLanguage` type in `visitor.ts` + - [ ] Imported and registered in the `translateWithMap()` switch in `translator.ts` + - [ ] Added to `parseCode()` in `useCodeParsing.ts` with lexer/parser imports + - [ ] Added to `SupportedLang` **and** `LANG_LABELS` in `LanguageSelector.tsx` + - [ ] Added to `SOURCE_OPTIONS` in `editor/SourcePane.tsx` + - [ ] Added to `PANEL_LANGS` in `editor/AddPanelStrip.tsx` (and `LanguageLogo`) + - [ ] Added to `VALID_TARGET_LANGS` / `VALID_TO_LANGS` if embed links should carry it - [ ] (Optional) Added CodeMirror extension in `editorUtils.ts` - [ ] **Testing** @@ -1398,6 +1412,9 @@ Before considering your language "complete," ensure: - [ ] Unit tests for parser - [ ] Integration tests (lex → parse → interpret) - [ ] Translation tests (lex → parse → translate to other languages) + - [ ] `examples/demo.` added and registered in `utils/demoPrograms.ts`, + so `round-trip.test.ts` checks every target + - [ ] `npm run test-browser` passes (the Selenium suite is not part of `test:run`) ## Conclusion diff --git a/docs/AST_REFERENCE.md b/docs/AST_REFERENCE.md index d393c24..efdd1d0 100644 --- a/docs/AST_REFERENCE.md +++ b/docs/AST_REFERENCE.md @@ -31,6 +31,7 @@ Every AST node type defined in [`src/language/ast.ts`](../src/language/ast.ts), | Return | visitReturn(stmt: any) | Return statement, with an optional value. | | Break | visitBreak(stmt: any) | `break` statement. | | Continue | visitContinue(stmt: any) | `continue` statement. | +| BlankLine | visitBlankLine(stmt: BlankLine) | A preserved blank line from the source, so translations keep the author's spacing. The **only** non-abstract `visit*` on `ASTVisitor`: every target emits it identically, and it must push `''` directly rather than go through `emit()`, which would prepend indent whitespace. The interpreter treats it as a no-op. | | Assignment | visitAssignment(stmt: any) | Variable or member assignment, e.g. `x = value`. `target` is the lvalue — an `Identifier` for a plain variable, or a `MemberExpression`/`IndexExpression` for a member/index mutation (`obj.field = x`, `arr[i] = x`); use `lvalueName(node)` for the plain-variable name (undefined for member/index). `varType` marks a typed declaration; `declaredWithoutInitializer` (emission-only, no effect on interpretation) marks a bare declaration like `int x;`. | | Print | visitPrint(stmt: any) | Output statement (`print`, `System.out.println`, etc.). `separator`/`appendLineFeed` mirror Python's `sep=`/`end=` (defaulting to `' '`/`true` in the interpreter); currently only the Praxis parser populates them, from natural-language phrasing. | | ExpressionStatement | visitExpressionStatement(stmt: any) | A bare expression used as a statement (e.g. a call for its side effect). | @@ -44,6 +45,7 @@ Every AST node type defined in [`src/language/ast.ts`](../src/language/ast.ts), | MemberExpression | _N/A — generateExpression() switch_ | Property/method access, e.g. `obj.field`. | | IndexExpression | _N/A — generateExpression() switch_ | Array/list indexing, e.g. `arr[i]`. Negative indices (`arr[-1]`) are supported; Python-style slicing (`arr[1:3]`) is not. | | ArrayLiteral | _N/A — generateExpression() switch_ | An array/list literal, e.g. `[1, 2, 3]`. | +| ArrayCreation | _N/A — generateExpression() switch_ | Sized array allocation with no element list, e.g. Java `new int[n]` (`elementType` + `size`). Distinct from `ArrayLiteral`; targets without a sized-allocation form emit an equivalent filled list. | | Identifier | _N/A — generateExpression() switch_ | A variable/name reference. | | ThisExpression | _N/A — generateExpression() switch_ | A `this` reference. | | Placeholder | _N/A — generateExpression() switch_ | A Praxis `/* ... */` placeholder for missing exam-question code (e.g. `if (/* missing code */)`); evaluates to a default `0` so a program with holes still runs, and lowers to `0` in non-Praxis targets. | diff --git a/docs/COMMON_ISSUES.md b/docs/COMMON_ISSUES.md index 501e550..709211c 100644 --- a/docs/COMMON_ISSUES.md +++ b/docs/COMMON_ISSUES.md @@ -442,7 +442,7 @@ Runtime Error: Undefined variable 'x' ```typescript const interpreter = new Interpreter(); try { - const output = interpreter.interpret(ast); + const output = interpreter.interpret(ast, source); console.log(output); } catch (e) { console.error(e); @@ -488,7 +488,7 @@ Ensure your parser creates `FunctionDeclaration` nodes and the interpreter handl ```typescript // Interpreter should have this logic -interpret(program: Program): string[] { +interpret(program: Program, sourceCode: string = ''): string[] { // First pass: register all functions for (const stmt of program.body) { if (stmt.type === 'FunctionDeclaration') { @@ -690,63 +690,56 @@ export class YourEmitter extends ASTVisitor { Added the language to the parser, but it doesn't appear in the UI. **Root Cause:** -Didn't update `SupportedLang` type or UI dropdown in [src/pages/EditorPage.tsx](../../src/pages/EditorPage.tsx). +The language was added to the compiler but not to the UI registries. `SupportedLang` +lives in [src/components/LanguageSelector.tsx](../src/components/LanguageSelector.tsx) +(that module exports no component, despite the name) and the pickers live with +their panes. **Fix:** -1. Update the type: +1. Update the type and the labels — `LANG_LABELS` is an exhaustive `Record`, so + a missing label is a compile error: ```typescript -export type SupportedLang = 'python' | 'java' | 'csp' | 'praxis' | 'yourNewLang' | 'ast'; -``` - -2. Update the dropdown: +export type SupportedLang = 'python' | 'java' | 'csp' | 'praxis' | 'javascript' | 'blocks' | 'ast'; -```tsx - +export const LANG_LABELS: Record = { + /* … */ yourNewLang: 'Your New Language', +}; ``` +2. Add it to `SOURCE_OPTIONS` in + [src/components/editor/SourcePane.tsx](../src/components/editor/SourcePane.tsx) + so it can be chosen as the _source_ language. + ### Issue: Cannot add translation panel for new language **Symptom:** -Dropdown shows the language, but "Add Panel" button doesn't work. +The source dropdown shows the language, but no button for it appears in the left +icon rail. **Root Cause:** -Language not added to the "Add Panel" dropdown. +The language is missing from `PANEL_LANGS`. **Fix:** -Find the "Add Panel" menu in [src/pages/EditorPage.tsx](../../src/pages/EditorPage.tsx) and add: - -```tsx -{ - showAddMenu && ( -
- - ... - -
- ); -} +Add it to `PANEL_LANGS` in +[src/components/editor/AddPanelStrip.tsx](../src/components/editor/AddPanelStrip.tsx): + +```typescript +const PANEL_LANGS: SupportedLang[] = [ + 'ast', + 'blocks', + 'csp', + 'java', + 'javascript', + 'praxis', + 'python', + 'yourNewLang', // ADD THIS +]; ``` +Add a case to `LanguageLogo` as well, or the button renders with no icon. + ### Issue: Parsing works, but no output shown **Symptom:** @@ -765,28 +758,27 @@ const parseCode = useCallback((lang: SupportedLang, input: string): Program | nu return ast; }, []); -// In EditorPage.tsx +// Straight from a test or the console const interpreter = new Interpreter(); -const output = interpreter.interpret(ast); -console.log('Output:', output); // ← Should show output +const output = interpreter.interpret(ast, source); +console.log('Output:', output); // ← string[], one entry per line ``` **Fix:** -Ensure the interpreter is called in the UI: - -```typescript -useEffect(() => { - try { - const ast = parseCode(sourceLang, code); - if (ast) { - const interpreter = new Interpreter(); - const output = interpreter.interpret(ast); - setOutput(output); // ← Don't forget this! - } - } catch (e) { - setError(e.message); - } -}, [code, sourceLang]); +In the app, running is driven by `useEditorExecution` (which delegates to +`useProgramRunner` so `input()` can pause a run). If output never appears, +check that hook rather than the page: + +```typescript +// src/hooks/useEditorExecution.ts +const run = () => { + clearConsole(); + setHasRun(true); + const program = parseCode(runLang, code); + if (!program) return; + setAst(program); + runner.run(program, runLang, code); // → onOutput(lines) → setOutput +}; ``` ## Debugging Techniques diff --git a/docs/COMPILER_PIPELINE.md b/docs/COMPILER_PIPELINE.md index 7e1382a..0293a3a 100644 --- a/docs/COMPILER_PIPELINE.md +++ b/docs/COMPILER_PIPELINE.md @@ -68,7 +68,7 @@ Notice the **virtual semicolons** — Python has no semicolons, but the lexer in ### How Lexers Work (token-by-token) -In [src/language/python/lexer.ts](../../src/language/python/lexer.ts), the lexer uses a position pointer and scans characters: +In [src/language/python/lexer.ts](../src/language/python/lexer.ts), the lexer uses a position pointer and scans characters: ```typescript // Simplified pseudocode of the lexer algorithm @@ -114,11 +114,11 @@ while (pos < input.length) { ### Key Files -- [src/language/lexer.ts](../../src/language/lexer.ts) — Base `Token` interface -- [src/language/python/lexer.ts](../../src/language/python/lexer.ts) — Python lexer with indentation handling -- [src/language/java/lexer.ts](../../src/language/java/lexer.ts) — Java lexer -- [src/language/csp/lexer.ts](../../src/language/csp/lexer.ts) — CSP (pseudocode) lexer -- [src/language/praxis/lexer.ts](../../src/language/praxis/lexer.ts) — Praxis lexer +- [src/language/lexer.ts](../src/language/lexer.ts) — Base `Token` interface +- [src/language/python/lexer.ts](../src/language/python/lexer.ts) — Python lexer with indentation handling +- [src/language/java/lexer.ts](../src/language/java/lexer.ts) — Java lexer +- [src/language/csp/lexer.ts](../src/language/csp/lexer.ts) — CSP (pseudocode) lexer +- [src/language/praxis/lexer.ts](../src/language/praxis/lexer.ts) — Praxis lexer ### Special Case: Python Indentation @@ -204,11 +204,11 @@ IDENTIFIER(x) OPERATOR(=) NUMBER(10) OPERATOR(+) NUMBER(5) 4. `assignment()` returns: ```typescript Assignment { - name: 'x' + target: Identifier { name: 'x' } value: BinaryExpression { - left: Literal { value: 10 } + left: Literal { value: 10, raw: '10' } operator: '+' - right: Literal { value: 5 } + right: Literal { value: 5, raw: '5' } } } ``` @@ -242,7 +242,7 @@ isAtEnd(): boolean ### The AST Nodes -The parser builds AST nodes defined in [src/language/ast.ts](../../src/language/ast.ts), such as: +The parser builds AST nodes defined in [src/language/ast.ts](../src/language/ast.ts), such as: ```typescript // Core node structure @@ -250,15 +250,19 @@ interface ASTNode { id: string; // Unique identifier for this node type: NodeType; // The specific node type (e.g., 'Assignment') loc?: { start: number; end: number }; // Character positions in source + leadingComments?: string[]; // Comments above this statement + trailingComment?: string; // Inline comment on the same line } // Example: Assignment statement interface Assignment extends ASTNode { type: 'Assignment'; - name: string; // Variable name - target?: Expression; // For array/object assignment + // The lvalue. An Identifier for a plain variable, or an + // IndexExpression / MemberExpression for `arr[i] = x` / `obj.f = x`. + target: Expression; value: Expression; // The right-hand side - varType?: string; // Type annotation (if any) + varType?: string; // Declared type, where the language has one + declaredWithoutInitializer?: boolean; // e.g. Java's `int x;` } // Example: If statement @@ -280,11 +284,11 @@ interface BinaryExpression extends ASTNode { ### Key Files -- [src/language/ast.ts](../../src/language/ast.ts) — AST node definitions -- [src/language/python/parser.ts](../../src/language/python/parser.ts) — Python parser (551 lines) -- [src/language/java/parser.ts](../../src/language/java/parser.ts) — Java parser (797 lines) -- [src/language/csp/parser.ts](../../src/language/csp/parser.ts) — CSP parser (445 lines) -- [src/language/praxis/parser.ts](../../src/language/praxis/parser.ts) — Praxis parser +- [src/language/ast.ts](../src/language/ast.ts) — AST node definitions +- [src/language/python/parser.ts](../src/language/python/parser.ts) — Python parser +- [src/language/java/parser.ts](../src/language/java/parser.ts) — Java parser +- [src/language/csp/parser.ts](../src/language/csp/parser.ts) — CSP parser +- [src/language/praxis/parser.ts](../src/language/praxis/parser.ts) — Praxis parser ## Phase 3A: Interpretation @@ -298,7 +302,7 @@ The **Interpreter** walks the AST node-by-node and executes it. It maintains: ### How the Interpreter Works -[src/language/interpreter.ts](../../src/language/interpreter.ts) implements a **tree-walking interpreter**: +[src/language/interpreter.ts](../src/language/interpreter.ts) implements a **tree-walking interpreter**: ```typescript export class Interpreter { @@ -306,7 +310,9 @@ export class Interpreter { private classes = new Map(); private output: string[] = []; - interpret(program: Program): string[] { + // `sourceCode` is retained for error locations. Returns one entry per + // output line — not a single joined string. + interpret(program: Program, sourceCode: string = ''): string[] { // Phase 1: Register all classes and functions for (const stmt of program.body) { if (stmt.type === 'ClassDeclaration') { @@ -510,7 +516,7 @@ class JavaInstance { ### Key Files -- [src/language/interpreter.ts](../../src/language/interpreter.ts) — Main interpreter class +- [src/language/interpreter.ts](../src/language/interpreter.ts) — Main interpreter class ## Phase 3B: Translation @@ -520,7 +526,7 @@ The **Translator** consumes the universal AST and generates equivalent source co ### The Visitor Pattern -The `ASTVisitor` base class ([src/language/visitor.ts](../../src/language/visitor.ts)) defines abstract methods for each node type: +The `ASTVisitor` base class ([src/language/visitor.ts](../src/language/visitor.ts)) defines abstract methods for each node type: ```typescript export abstract class ASTVisitor { @@ -559,7 +565,7 @@ Each language has an **Emitter** that extends `ASTVisitor` and implements these ### Example: Python Emitter -[src/language/python/emitter.ts](../../src/language/python/emitter.ts): +[src/language/python/emitter.ts](../src/language/python/emitter.ts): ```typescript export class PythonEmitter extends ASTVisitor { @@ -614,7 +620,7 @@ export class PythonEmitter extends ASTVisitor { ### Example: Java Emitter -[src/language/java/emitter.ts](../../src/language/java/emitter.ts) works similarly but generates Java syntax: +[src/language/java/emitter.ts](../src/language/java/emitter.ts) works similarly but generates Java syntax: ```typescript export class JavaEmitter extends ASTVisitor { @@ -648,7 +654,7 @@ export class JavaEmitter extends ASTVisitor { ### Type Inference for Translation -Before translation, the `Translator` runs an analysis pass ([src/language/translator.ts](../../src/language/translator.ts)) to infer types: +Before translation, the `Translator` runs an analysis pass ([src/language/translator.ts](../src/language/translator.ts)) to infer types: ```typescript private analyze(program: Program): TranslationContext { @@ -707,12 +713,16 @@ This is essential for translating from **dynamically-typed languages (Python) to ### Key Files -- [src/language/translator.ts](../../src/language/translator.ts) — Main translator orchestrator -- [src/language/visitor.ts](../../src/language/visitor.ts) — Abstract ASTVisitor base class -- [src/language/python/emitter.ts](../../src/language/python/emitter.ts) — Python code generator (369 lines) -- [src/language/java/emitter.ts](../../src/language/java/emitter.ts) — Java code generator -- [src/language/csp/emitter.ts](../../src/language/csp/emitter.ts) — CSP code generator -- [src/language/praxis/emitter.ts](../../src/language/praxis/emitter.ts) — Praxis code generator +- [src/language/translator.ts](../src/language/translator.ts) — Main translator orchestrator +- [src/language/visitor.ts](../src/language/visitor.ts) — Abstract ASTVisitor base class +- [src/language/python/emitter.ts](../src/language/python/emitter.ts) — Python code generator +- [src/language/java/emitter.ts](../src/language/java/emitter.ts) — Java code generator +- [src/language/csp/emitter.ts](../src/language/csp/emitter.ts) — CSP code generator +- [src/language/praxis/emitter.ts](../src/language/praxis/emitter.ts) — Praxis code generator +- [src/language/javascript/emitter.ts](../src/language/javascript/emitter.ts) — JavaScript code generator +- [src/language/blocks/fromAst.ts](../src/language/blocks/fromAst.ts) — Blocks "emitter": produces + Blockly workspace JSON. Handled before the emitter switch in `translateWithMap()`, + because it is not an `ASTVisitor` and emits no line-based source map. ## Data Flow Example @@ -832,7 +842,7 @@ greet("World"); The pipeline is integrated into the React UI via hooks: -### [src/hooks/useCodeParsing.ts](../../src/hooks/useCodeParsing.ts) +### [src/hooks/useCodeParsing.ts](../src/hooks/useCodeParsing.ts) ```typescript export const useCodeParsing = () => { @@ -862,42 +872,42 @@ export const useCodeParsing = () => { }; ``` -### [src/pages/EditorPage.tsx](../../src/pages/EditorPage.tsx) snippet +### [src/hooks/useEditorExecution.ts](../src/hooks/useEditorExecution.ts) -```typescript -export default function EditorPage() { - const [code, setCode] = useState(SAMPLE_CODE_PYTHON); - const [ast, setAst] = useState(null); - const [output, setOutput] = useState([]); - - const { parseCode, getTranslation } = useCodeParsing(); - - const { debuggerInstance, initDebugger } = useCodeDebugger(getTranslation); - - // On code change: parse, translate, and interpret - useEffect(() => { - try { - const parsed = parseCode(sourceLang, code); // Lex + Parse - setAst(parsed); - - const interpreter = new Interpreter(); - const result = interpreter.interpret(parsed); // Step 3A - setOutput(result); - - // Update all open translation panels - panels.forEach((panel) => { - const { code } = getTranslation(parsed, panel.lang); // Step 3B - // Display code in panel - }); - } catch (e) { - setError(e.message); - } - }, [code, sourceLang]); +`EditorPage` itself is composition only — it holds the source text and wires +panes together. The pipeline is driven from `useEditorExecution`, which re-parses +on every edit and runs on demand: - // Etc. -} +```typescript +// Step 1 + 2, on every keystroke: the AST feeds the translation panes, +// the AST view, and the AI panel's context. +useEffect(() => { + if (sourceLang === 'ast') return; + try { + setAst(parseCode(sourceLang, code)); + setError(null); + } catch (e: any) { + setAst(null); + setError(e.message); + } +}, [code, sourceLang, parseCode]); + +// Step 3A, on Run. Executed through `Debugger` rather than `Interpreter` +// directly (see useProgramRunner) so a program that calls input() can pause +// and resume instead of re-running from the top. +const run = () => { + const program = parseCode(runLang, code); + if (!program) return; + setAst(program); + runner.run(program, runLang, code); // → onOutput(lines) +}; ``` +Step 3B happens per pane at render time — `getTranslation(ast, panel.lang)` — +while [`usePanelSourceMaps`](../src/hooks/usePanelSourceMaps.ts) refreshes each +panel's `nodeId → line` map whenever the AST changes, so debugger highlighting +stays aligned across every open translation. + ## Summary The pipeline is a classic three-phase compilation system: diff --git a/docs/COMPONENT_REFERENCE.md b/docs/COMPONENT_REFERENCE.md index 115bf55..f284c62 100644 --- a/docs/COMPONENT_REFERENCE.md +++ b/docs/COMPONENT_REFERENCE.md @@ -13,7 +13,7 @@ Detailed API documentation for key classes in the Praxly compiler. ## Lexer Classes -All lexers inherit from a common pattern defined in [src/language/lexer.ts](../../src/language/lexer.ts). +All lexers inherit from a common pattern defined in [src/language/lexer.ts](../src/language/lexer.ts). ### Base Token Interface @@ -29,9 +29,11 @@ type TokenType = | 'IDENTIFIER' | 'NUMBER' | 'STRING' + | 'CHAR' | 'BOOLEAN' | 'OPERATOR' | 'PUNCTUATION' + | 'PLACEHOLDER' | 'NEWLINE' | 'INDENT' | 'DEDENT' @@ -48,7 +50,7 @@ Every language lexer must: ### Example: Python Lexer -**Location:** [src/language/python/lexer.ts](../../src/language/python/lexer.ts) +**Location:** [src/language/python/lexer.ts](../src/language/python/lexer.ts) ```typescript export class Lexer { @@ -72,7 +74,7 @@ const tokens = lexer.tokenize(); ### Example: Java Lexer -**Location:** [src/language/java/lexer.ts](../../src/language/java/lexer.ts) +**Location:** [src/language/java/lexer.ts](../src/language/java/lexer.ts) ```typescript export class JavaLexer { @@ -147,7 +149,7 @@ Return true if at EOF. ### Example: Python Parser -**Location:** [src/language/python/parser.ts](../../src/language/python/parser.ts) +**Location:** [src/language/python/parser.ts](../src/language/python/parser.ts) ```typescript export class Parser { @@ -185,17 +187,24 @@ export class Parser { **Key Method Patterns:** -Statement parsing: +Statement parsing — the real Python `ifStatement`. Note that `elif` has no +Universal AST node: it desugars into an `If` nested inside the `elseBranch` +block, which is the pattern to follow whenever a language's surface syntax has +no direct AST equivalent. ```typescript private ifStatement(): If { this.consume('KEYWORD', 'if'); - this.consume('PUNCTUATION', '('); - const condition = this.expression(); - this.consume('PUNCTUATION', ')'); + const condition = this.expression(); // no parens — Python doesn't use them const thenBranch = this.block(); - let elseBranch: Block | undefined; - if (this.match('KEYWORD', 'else')) { + + let elseBranch: Block | undefined = undefined; + + while (this.match('PUNCTUATION', ';')) {} // skip virtual line terminators + if (this.match('KEYWORD', 'elif')) { + const elifIf = this.ifStatementElif(); + elseBranch = { id: generateId(), type: 'Block', body: [elifIf] }; + } else if (this.match('KEYWORD', 'else')) { elseBranch = this.block(); } return { id: generateId(), type: 'If', condition, thenBranch, elseBranch }; @@ -226,55 +235,90 @@ private term(): Expression { ## Interpreter -**Location:** [src/language/interpreter.ts](../../src/language/interpreter.ts) +**Location:** [src/language/interpreter.ts](../src/language/interpreter.ts) ### Main Interpreter Class ```typescript export class Interpreter { - constructor() + constructor(); + + // Execute a complete program. Returns one entry per output line — + // NOT a single joined string. `sourceCode` is used for error locations. + interpret(program: Program, sourceCode?: string): string[]; - // Execute a complete program - interpret(program: Program): string[] + // Debug: step through the program, yielding state after each step. + *stepThroughWithState( + program: Program, + sourceCode?: string + ): Generator; - // Debug: step through program with state inspection - *stepThroughWithState(program: Program): Generator<...> + // Call stack at the current point, global scope first. + getStackFrames(): StackFrame[]; - // Execute a block of statements - executeBlock(statements: Statement[], env: Environment): void + // Cumulative output so far (used by the Debugger between steps). + getOutput(): string[]; - // Execute a single statement - executeStatement(stmt: Statement, env: Environment): void + // Execute a block of statements in a scope + executeBlock(statements: Statement[], env: Environment): void; // Evaluate an expression to a value - evaluate(expr: Expression, env: Environment): any + evaluate(expr: Expression, env: Environment, expectedType?: string): any; + + // stdin for input(): queue answers ahead of time, or feed them as prompted + setInputQueue(inputs: string[]): void; + addInput(input: string): void; +} +``` + +**Debug types:** + +```typescript +export interface StackFrame { + name: string; + variables: Record; +} - // Register a class for OOP support - private registerClass(classDecl: ClassDeclaration): void +export interface DebugStepEvent { + nodeId: string; + nodeType: string; + loc: { start: number; end: number } | null; + /** Flat "visible right now" view: globals shadowed by the current frame's locals. */ + variables: Record; + /** Global scope first, innermost call last. */ + callStack: StackFrame[]; + prompt?: string; } ``` +**Control-flow signalling.** `return`, `break`, and `continue` are implemented as +thrown exceptions (`ReturnException`, `BreakException`, `ContinueException`), and +a program that calls `input()` with no queued answer throws `InputPrompt` to +suspend execution. If you add a `try`/`catch` inside the interpreter, re-throw +these rather than swallowing them. + ### Environment Class Variable scoping with nested environments: ```typescript export class Environment { - public values: Record = {}; - public parent?: Environment; - constructor(parent?: Environment); - // Define a variable in current scope - define(name: string, value: any): void; + // Define a variable in the current scope. `type` and `declarationOrigin` + // carry the declared type and source offset for error messages. + define(name: string, value: any, type?: string, declarationOrigin?: number): void; - // Assign to variable (searches parent scopes) + // Assign to an existing variable (searches parent scopes) assign(name: string, value: any): void; - // Retrieve variable value (searches parent scopes) + // Retrieve a variable's value (searches parent scopes) get(name: string): any; - // Get all variables in all scopes + // Declared type of a variable, if one was recorded + getType(name: string): string | undefined; + + // Every variable visible from here, parents included getAllVariables(): Record; } ``` @@ -324,7 +368,7 @@ class JavaInstance { ### Translator Class -**Location:** [src/language/translator.ts](../../src/language/translator.ts) +**Location:** [src/language/translator.ts](../src/language/translator.ts) ```typescript export class Translator { @@ -342,9 +386,13 @@ export class Translator { **TargetLanguage Type:** ```typescript -export type TargetLanguage = 'java' | 'python' | 'csp' | 'praxis'; +export type TargetLanguage = 'java' | 'python' | 'csp' | 'praxis' | 'javascript' | 'blocks'; ``` +`'blocks'` is handled _before_ the emitter switch in `translateWithMap()` — it +is produced by `programToBlocksJson()` (`src/language/blocks/fromAst.ts`) rather +than by an `ASTVisitor`, and returns an empty source map. + **TranslationResult:** ```typescript @@ -356,66 +404,96 @@ export interface TranslationResult { ### ASTVisitor Base Class -**Location:** [src/language/visitor.ts](../../src/language/visitor.ts) +**Location:** [src/language/visitor.ts](../src/language/visitor.ts) ```typescript export abstract class ASTVisitor { - protected code: string = ''; - protected indentLevel: number = 0; + protected output: string[] = []; + protected indentLevel = 0; protected context: TranslationContext; + protected breakStr = 'break;'; + protected continueStr = 'continue;'; + protected sourceMap: SourceMap = new Map(); + /** Line-comment delimiter for this target (Python overrides to `#`). */ + protected commentPrefix = '//'; constructor(context: TranslationContext); - // ===== ABSTRACT METHODS: Subclasses must implement ===== - abstract visitProgram(program: Program): void; - abstract visitStatement(statement: Statement): void; - abstract visitExpression(expression: Expression): void; - // ... etc for each node type - - // ===== CONCRETE METHODS: Subclasses inherit ===== + // ===== CONCRETE: inherited by every emitter ===== + /** Appends a line at the current indent. Pass `nodeId` to record it in the source map. */ protected emit(line: string, nodeId?: string): void; protected indent(): void; protected dedent(): void; + /** Re-adds `commentPrefix` to preserved comment lines. */ + protected emitComments(lines?: string[]): void; + /** Escapes a runtime string back into source-literal form so it re-parses. */ + protected escapeString(value: string, quote?: '"' | "'"): string; + protected inferType(expr: Expression): string; + + /** Dispatcher: emits leading comments, routes to the right visit*, appends trailing comment. */ + visitStatement(stmt: Statement): void; + /** Concrete, not abstract — every target emits a preserved blank line identically. */ + visitBlankLine(stmt: BlankLine): void; getGeneratedCode(): string; getSourceMap(): SourceMap; - protected abstract generateExpression(expr: Expression, minPrecedence: number): string; -} -``` + // ===== ABSTRACT: 21 visit methods + expression generation ===== + abstract visitProgram(program: Program): void; + abstract visitBlock(block: Block): void; + abstract visitClassDeclaration(classDecl: ClassDeclaration): void; + abstract visitMethodDeclaration(method: MethodDeclaration): void; + abstract visitFieldDeclaration(field: FieldDeclaration): void; + abstract visitConstructor(ctor: Constructor): void; + abstract visitPrint(stmt: any): void; + abstract visitAssignment(stmt: any): void; + abstract visitIf(stmt: any): void; + abstract visitWhile(stmt: any): void; + abstract visitDoWhile(stmt: any): void; + abstract visitRepeatUntil(stmt: any): void; + abstract visitSwitch(stmt: any): void; + abstract visitBreak(stmt: any): void; + abstract visitContinue(stmt: any): void; + abstract visitFor(stmt: any): void; + abstract visitForEach(stmt: any): void; + abstract visitFunctionDeclaration(stmt: any): void; + abstract visitReturn(stmt: any): void; + abstract visitExpressionStatement(stmt: any): void; + abstract visitTry(stmt: any): void; + + abstract generateExpression(expr: Expression, parentPrecedence: number): string; +} +``` + +Adding a `visit*` method means adding it as `abstract` here, adding a `case` to +the private `dispatchStatement()` switch, and implementing it in all five +emitters — `tsc` will list the ones you miss. + +**Precedence constants** (`Member: 18` down to `Sequence: 1`) live in the same +file; pass them as `parentPrecedence` so `generateExpression` knows when to +parenthesise. ### Example: Python Emitter -**Location:** [src/language/python/emitter.ts](../../src/language/python/emitter.ts) +**Location:** [src/language/python/emitter.ts](../src/language/python/emitter.ts) ```typescript export class PythonEmitter extends ASTVisitor { constructor(context: TranslationContext); - visitProgram(program: Program): void; - visitClassDeclaration(classDecl: ClassDeclaration): void; - visitFieldDeclaration(field: FieldDeclaration): void; - visitConstructor(ctor: Constructor): void; - visitMethodDeclaration(method: MethodDeclaration): void; - visitBlock(block: Block): void; - visitPrint(stmt: Print): void; - visitAssignment(stmt: Assignment): void; - visitFunctionDeclaration(func: FunctionDeclaration): void; - visitReturn(stmt: Return): void; - visitIf(stmt: If): void; - visitWhile(stmt: While): void; - visitFor(stmt: For): void; - visitBreak(stmt: Break): void; - visitContinue(stmt: Continue): void; - - protected generateExpression(expr: Expression, minPrecedence: number): string; + // All 21 abstract visit* methods, plus: + generateExpression(expr: Expression, parentPrecedence: number): string; } ``` +Python overrides `commentPrefix` to `'#'`, and its `breakStr`/`continueStr` drop +the trailing semicolon. Any target whose comment or jump syntax differs from +C-style should do the same rather than special-casing at each emit site. + ### SymbolTable Class -**Location:** [src/language/visitor.ts](../../src/language/visitor.ts) +**Location:** [src/language/visitor.ts](../src/language/visitor.ts) Manages type information across nested scopes: @@ -447,7 +525,7 @@ table.get('y'); // undefined (no longer in scope) ## AST Nodes -All AST nodes are defined in [src/language/ast.ts](../../src/language/ast.ts). +All AST nodes are defined in [src/language/ast.ts](../src/language/ast.ts). ### Base ASTNode Interface @@ -456,6 +534,10 @@ export interface ASTNode { id: string; // Unique identifier (generated via generateId()) type: NodeType; // The specific node type loc?: { start: number; end: number }; // Character positions in source + // Source comments carried through translation (delimiter stripped; the + // emitter re-adds the target's `//` or `#`). Populated on statements only. + leadingComments?: string[]; // own-line comments directly above this statement + trailingComment?: string; // inline comment after this statement, same line } ``` @@ -484,15 +566,17 @@ interface Block extends ASTNode { ```typescript interface Assignment extends ASTNode { type: 'Assignment'; - name: string; - target?: Expression; // For array/map assignments + target: Expression; // Identifier, IndexExpression, or MemberExpression value: Expression; - varType?: string; // Type annotation - isMemberAssignment?: boolean; - memberExpr?: Expression; + varType?: string; // Declared type, where the source language has one + declaredWithoutInitializer?: boolean; // e.g. Java's `int x;` } ``` +The target is always an `Expression` — there is no separate `name` field, and +member/index assignment is expressed by the target's node type rather than by a +flag. + **If** — Conditional statement ```typescript @@ -511,22 +595,26 @@ interface While extends ASTNode { type: 'While'; condition: Expression; body: Block; - elseBranch?: Block; // Python-style else on loop } ``` -**For** — For loop +`DoWhile` and `RepeatUntil` are separate node types rather than flags on +`While`; CSP's `REPEAT UNTIL` parses to a `While` with a negated condition. + +**For** — C-style for loop ```typescript interface For extends ASTNode { type: 'For'; - init?: Expression; - condition: Expression; - update?: Expression; + init?: Statement; // a Statement, not an Expression + condition?: Expression; + update?: Statement; body: Block; } ``` +`ForEach` is the separate node for `for … in` / `FOR EACH` iteration. + **FunctionDeclaration** — Function definition ```typescript @@ -535,6 +623,9 @@ interface FunctionDeclaration extends ASTNode { name: string; params: Parameter[]; body: Block; + // Declared return type where the source language has one (Praxis/Java); + // absent for Python/JS/CSP, whose emitters infer it from the body. + returnType?: string; } ``` @@ -554,7 +645,7 @@ interface ClassDeclaration extends ASTNode { ```typescript interface Return extends ASTNode { type: 'Return'; - argument?: Expression; + value?: Expression; // named `value`, not `argument` } ``` @@ -564,6 +655,8 @@ interface Return extends ASTNode { interface Print extends ASTNode { type: 'Print'; expressions: Expression[]; + separator?: string; // inserted between expressions + appendLineFeed?: boolean; // false for CSP's DISPLAY, which appends a space } ``` @@ -575,7 +668,10 @@ interface Print extends ASTNode { interface Literal extends ASTNode { type: 'Literal'; value: any; // boolean, number, string, null, etc. - raw?: string; // Optional: original text from source + // Required. Preserves the original source text (e.g. `1.0` vs `1`, or an + // f/r/b string prefix) so emitters can round-trip formatting that + // stringifying `value` would lose — and so `1.0` infers as double, not int. + raw: string; } ``` @@ -614,7 +710,7 @@ interface UnaryExpression extends ASTNode { ```typescript interface CallExpression extends ASTNode { type: 'CallExpression'; - callee: Expression; // Function to call + callee: Identifier | MemberExpression; // narrower than Expression arguments: Expression[]; } ``` @@ -626,9 +722,12 @@ interface MemberExpression extends ASTNode { type: 'MemberExpression'; object: Expression; property: Identifier; + isMethod: boolean; // required — distinguishes `a.b()` from `a.b` } ``` +**IndexExpression** — `a[i]` element access, distinct from `MemberExpression`. + **ArrayLiteral** — Array construction ```typescript @@ -652,7 +751,7 @@ interface NewExpression extends ASTNode { ### generateId() -**Location:** [src/language/ast.ts](../../src/language/ast.ts) +**Location:** [src/language/ast.ts](../src/language/ast.ts) ```typescript export function generateId(): string; @@ -672,7 +771,7 @@ const node = { ### useCodeParsing Hook -**Location:** [src/hooks/useCodeParsing.ts](../../src/hooks/useCodeParsing.ts) +**Location:** [src/hooks/useCodeParsing.ts](../src/hooks/useCodeParsing.ts) ```typescript export const useCodeParsing = () => { @@ -697,41 +796,73 @@ const { code, sourceMap } = getTranslation(ast, 'java'); ### useCodeDebugger Hook -**Location:** [src/hooks/useCodeDebugger.ts](../../src/hooks/useCodeDebugger.ts) +**Location:** [src/hooks/useCodeDebugger.ts](../src/hooks/useCodeDebugger.ts) + +Wraps a `Debugger` instance in React state. `stepDebugger` returns the +highlighting and output for one step; the hook also mirrors them into state. ```typescript -export const useCodeDebugger = (getTranslation: (ast: Program | null, lang: SupportedLang) => { ... }) => { - const initDebugger = (ast: Program | null): void => { ... } - const stopDebugger = (): void => { ... } +export const useCodeDebugger = ( + getTranslation: (ast: Program | null, target: SupportedLang) => { code: string; sourceMap: SourceMap } +) => ({ + isDebugging, setIsDebugging, + isDebugComplete, setIsDebugComplete, + debuggerInstance, + highlightedSourceLines, setHighlightedSourceLines, + highlightedTranslationLines, setHighlightedTranslationLines, + currentVariables, setCurrentVariables, + currentCallStack, + waitingForInput, inputPrompt, - return { - isDebugging: boolean, - setIsDebugging: (b: boolean) => void, - isDebugComplete: boolean, - setIsDebugComplete: (b: boolean) => void, - debuggerInstance: DebuggerInstance | null, - highlightedSourceLines: number[], - currentVariables: Record, - initDebugger, - stopDebugger - }; -} + initDebugger: (ast: Program | null, lang: SupportedLang, sourceCode?: string) => boolean | undefined, + stepDebugger: (ast: Program | null, sourceCode: string, target: SupportedLang) => DebugStepResult | null, + stopDebugger: () => void, + provideInput: (input: string) => void, +}); +``` + +### useProgramRunner Hook + +**Location:** [src/hooks/useProgramRunner.ts](../src/hooks/useProgramRunner.ts) + +Drives a plain (non-debug) run. It runs on `Debugger` rather than `Interpreter` +directly so a program calling `input()` can pause and resume instead of being +re-executed from the top. Shared by the editor and the embed player. + +```typescript +export function useProgramRunner(callbacks: { + onOutput: (lines: string[]) => void; + onError: (message: string) => void; +}): { + waitingForInput: boolean; + inputPrompt: string; + run: (program: Program, lang: SupportedLang, sourceCode: string) => void; + submitInput: (input: string) => void; + reset: () => void; +}; ``` +For the full hook inventory, see [README.md](./README.md#directory-structure) +and the `add-ui-feature` skill. + ## Type Definitions Summary -**TargetLanguage:** +**TargetLanguage** — what the translator can emit: ```typescript -type TargetLanguage = 'java' | 'python' | 'csp' | 'praxis'; +type TargetLanguage = 'java' | 'python' | 'csp' | 'praxis' | 'javascript' | 'blocks'; ``` -**SupportedLang:** +**SupportedLang** — what the UI can show. Adds `'ast'` (a read-only view, not an +emitter target) on top of the text languages plus `'blocks'`: ```typescript -type SupportedLang = 'python' | 'java' | 'csp' | 'praxis' | 'javascript' | 'ast'; +type SupportedLang = 'python' | 'java' | 'csp' | 'praxis' | 'javascript' | 'blocks' | 'ast'; ``` +Defined in [src/components/LanguageSelector.tsx](../src/components/LanguageSelector.tsx) +alongside `LANG_LABELS`. Despite the filename, that module exports no component. + **TranslationContext:** ```typescript @@ -739,6 +870,9 @@ interface TranslationContext { symbolTable: SymbolTable; functionReturnTypes: Map; functionParamTypes: Map; + mutableCollections?: Set; + collectionElementTypes?: Map; + inferredVariableTypes?: Map; } ``` diff --git a/docs/README.md b/docs/README.md index bdd2fae..2e8969c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -80,10 +80,13 @@ See [AST_REFERENCE.md](AST_REFERENCE.md) for a reference of all the node types. ``` Python: x = 10 + 5 ──┐ -Java: int x = 10 + 5; ──┼──→ Same Universal AST -CSP: SET x TO 10 + 5 ──┘ +Java: int x = 10 + 5; ┼──→ Same Universal AST +CSP: x ← 10 + 5 ──┘ ``` +(CSP assigns with `←`; `<-` and `⟵` are accepted too. `=` is CSP's _equality_ +operator — see [`specs/csp.md`](../specs/csp.md).) + This design has profound implications: - **Single Interpreter** — We need only one Interpreter class that walks any AST @@ -109,34 +112,49 @@ src/ ├── main.tsx # Entry point ├── index.css # Global styles + Tailwind imports ├───────────────────────────────────────────────────────────── -├── components/ # Reusable React UI components -│ ├── CodeEditorPanel.tsx # Left-side editor (CodeMirror) +├── components/ # React UI components │ ├── ConfirmModal.tsx # Confirmation dialog -│ ├── HighlightableCodeMirror.tsx +│ ├── HighlightableCodeMirror.tsx # Read-only pane w/ debug line highlighting │ ├── JSONTree.tsx # Recursive AST viewer │ ├── LanguageLogo.tsx # Per-language icon -│ ├── LanguageSelector.tsx # Dropdown for language selection -│ ├── OutputPanel.tsx # Shows console output + execution results +│ ├── LanguageSelector.tsx # SupportedLang + LANG_LABELS (types only) +│ ├── OutputPanel.tsx # Console output, stdin prompt, debug variables │ ├── ResizeHandle.tsx # Draggable column dividers -│ ├── TranslationPanel.tsx # Right panels for translated code +│ ├── VariableFrames.tsx # Per-call-frame variable rendering │ ├── ai/ # AI chat assistant components +│ ├── account/ # Account page sections (one file per pane) +│ ├── embed/ # Embed player panes │ └── editor/ # Editor-specific sub-components -│ ├── AddPanelStrip.tsx # Strip for adding new language panels +│ ├── AddPanelStrip.tsx # Left rail; PANEL_LANGS lives here │ ├── AiSidePanel.tsx # AI assistant side panel +│ ├── AskAiButton.tsx # Floating highlight-to-chat button │ ├── BlocklyPane.tsx # Blocks (Blockly) workspace panel │ ├── BlocklyPaneLazy.tsx # Lazy-loaded wrapper for BlocklyPane +│ ├── EditorDialogs.tsx # The three "discard your code?" confirmations │ ├── EditorHeader.tsx # Toolbar, run/debug buttons │ ├── MemDia.tsx # Memory diagram visualization -│ ├── SourcePane.tsx # Left source editor panel -│ └── TranslationPaneItem.tsx # Right-side translation panels +│ ├── SourcePane.tsx # Source editor pane; SOURCE_OPTIONS lives here +│ ├── TranslationPaneItem.tsx # A single translation pane +│ ├── TranslationPanelGrid.tsx # Columns, stacking, elastic last column +│ ├── layoutConstants.ts # Pixel budgets for every pane +│ └── types.ts # The Panel type │───────────────────────────────────────────────────────────── -├── hooks/ # React custom hooks -│ ├── useCodeParsing.ts # Handles parsing + translation -│ ├── useCodeDebugger.ts # Manages debug state + stepping -│ └── useClickOutside.ts # Dismiss-on-outside-click helper +├── hooks/ # All page behaviour lives here +│ ├── useCodeParsing.ts # Source text → AST → translation + source map +│ ├── useCodeDebugger.ts # Step-through debug state machine +│ ├── useProgramRunner.ts # Plain run that pauses on input() and resumes +│ ├── useEditorExecution.ts # Editor: parse-on-type, run, debug, console +│ ├── useEmbedExecution.ts # Embed: the same, without panels +│ ├── useEditorLayout.ts # Pane widths/heights + every resize drag +│ ├── useTranslationPanels.ts # Open panels, columns, drag-and-drop +│ ├── useMemDiaPanes.ts # Per-pane memory diagram height/state +│ ├── useEditorSession.ts # localStorage persistence +│ ├── useAccountData.ts # Account profile/usage/chats + status banner +│ └── … # menus, shortcuts, AI selection, embed links, +│ # text size, click-outside, CodeMirror ref │───────────────────────────────────────────────────────────── -├── pages/ # Route page components -│ ├── EditorPage.tsx # Main editor IDE (the heart of the UI) +├── pages/ # Route pages — composition only +│ ├── EditorPage.tsx # Main editor IDE │ ├── EmbedPage.tsx # Shareable code embed view │ └── AccountPage.tsx # Account page │───────────────────────────────────────────────────────────── @@ -145,8 +163,9 @@ src/ │ ├── lexer.ts # Base Token types │ ├── interpreter.ts # AST interpreter (execution engine) │ ├── translator.ts # Main translation orchestrator -│ ├── visitor.ts # Abstract ASTVisitor base class -│ ├── debugger.ts # Debugging support +│ ├── visitor.ts # Abstract ASTVisitor base class + Precedence +│ ├── debugger.ts # Step-through wrapper over the interpreter +│ ├── comments.ts # Attaches source comments to AST nodes │ │ │ ├── python/ # Python language support │ │ ├── lexer.ts # Tokenizes Python (handles indentation!) @@ -187,17 +206,30 @@ src/ │ ├── blocklyDialogs.ts # Blockly dialog UI │ └── serialization.ts # Workspace JSON serialization │───────────────────────────────────────────────────────────── -└── utils/ # Utilities and helpers - ├── codemirrorConfig.ts # CodeMirror extensions - ├── debuggerUtils.ts # Debugging helper functions - ├── debugHandlers.ts # Source map → line highlighting logic - ├── editorUtils.ts # Editor-specific utilities (CodeMirror lang per SupportedLang) +├── store/appStore.ts # Zustand: chat, AI prefs, BYOK, editor bridge +│ # (NOT editor state — that lives in hooks/) +├── api/ # Backend clients: auth, account, chat, llm +│───────────────────────────────────────────────────────────── +└── utils/ # Helpers; the first four are pure and + │ # unit-testable with no DOM + ├── aiPanelContext.ts # Builds the AI panel's code context + ├── codemirrorConfig.ts # CodeMirror state fields + highlight dispatch + ├── debuggerUtils.ts # Source range → line numbers + ├── debugHandlers.ts # Source map → per-panel line highlighting + ├── demoPrograms.ts # Per-language demos, re-exported from examples/ + ├── editorUtils.ts # CodeMirror language extension per SupportedLang ├── embedCodec.ts # URL embedding/sharing logic ├── id.ts # ID generation helpers - ├── panelLayout.ts # Translation panel layout logic - └── sampleCodes.ts # Default sample code for each language + ├── languageSwitch.ts # Can this program survive a language change? + ├── panelLayout.ts # Translation panel column/stacking rules + └── sampleCodes.ts # EXAMPLE_PROGRAMS catalog for the Examples menu ``` +Outside `src/`: [`specs/`](../specs/) holds the authoritative language +definitions, [`examples/`](../examples/) the per-language demo programs, +[`tests/`](../tests/) the Vitest suites, and [`csv/`](../csv/) the Selenium +regression matrix. + ## The Compilation Pipeline at a Glance For more details, see [COMPILER_PIPELINE.md](COMPILER_PIPELINE.md). @@ -227,15 +259,19 @@ The parser verifies tokens follow valid grammar and builds a tree: ``` Assignment { - name: 'x' + target: Identifier { name: 'x' } value: BinaryExpression { - left: Literal { value: 10 } + left: Literal { value: 10, raw: '10' } operator: '+' - right: Literal { value: 5 } + right: Literal { value: 5, raw: '5' } } } ``` +`target` is an expression, not a name — that is what lets `arr[i] = x` and +`obj.field = x` reuse the same node with an `IndexExpression`/`MemberExpression` +target. + Parsers use **Recursive Descent**, which means: - Each grammar rule is a method @@ -248,11 +284,14 @@ The `Interpreter` class walks the AST and executes it: ```typescript // Execute the Assignment node -this.values['x'] = this.evaluate(rightHandSide); -// Now x = 15, and this.output = ['x is 15'] (if we printed it) +env.define('x', this.evaluate(stmt.value, env)); +// Now x = 15, and getOutput() === ['x is 15'] (if we printed it) ``` -The interpreter maintains an `Environment` for variable scoping. +The interpreter maintains an `Environment` for variable scoping — a chain of +`values` records, each linked to its parent, so `get()` walks outward until it +finds the name. `interpret()` returns the collected output as `string[]`, one +entry per line. ### Phase 3B: Translation (Code Generation) @@ -345,8 +384,16 @@ Quick summary: 3. Implement `parser.ts` (build Universal AST) 4. Implement `emitter.ts` (generate code from AST) 5. Update `src/language/visitor.ts` (`TargetLanguage` union) and `src/language/translator.ts` to register your language -6. Update `src/hooks/useCodeParsing.ts` to route parsing/translation for the new language -7. Update `src/components/LanguageSelector.tsx`, `src/components/editor/AddPanelStrip.tsx`, and `src/utils/editorUtils.ts` to expose it in the UI +6. Update `src/hooks/useCodeParsing.ts` to route parsing for the new language +7. Expose it in the UI: `SupportedLang` **and** `LANG_LABELS` in + `src/components/LanguageSelector.tsx`, `SOURCE_OPTIONS` in + `src/components/editor/SourcePane.tsx`, `PANEL_LANGS` in + `src/components/editor/AddPanelStrip.tsx`, and a `case` in + `getCodeMirrorExtensions()` in `src/utils/editorUtils.ts` +8. Add `examples/demo.` + `src/utils/demoPrograms.ts`, and `tests/.test.ts` + +The [`add-language` skill](../.claude/skills/add-language/SKILL.md) has the same +list with the symptom you'll see if you miss each step. ## Troubleshooting diff --git a/package-lock.json b/package-lock.json index a67a52c..6ca2bd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,9 +46,9 @@ "lint-staged": "^17.0.8", "lucide-react": "^1.23.0", "prettier": "^3.6.2", - "react": "^19.2.0", - "react-dom": "^19.2.0", - "react-router-dom": "^7.13.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router": "^8.3.0", "selenium-webdriver": "^4.41.0", "tsx": "^4.20.6", "typescript": "^6.0.3", @@ -5519,19 +5519,12 @@ "dev": true, "license": "MIT" }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } + "license": "MIT" }, "node_modules/core-util-is": { "version": "1.0.3", @@ -8912,24 +8905,24 @@ } }, "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.4" + "react": "^19.2.8" } }, "node_modules/react-markdown": { @@ -9007,21 +9000,20 @@ } }, "node_modules/react-router": { - "version": "7.18.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", - "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.3.0.tgz", + "integrity": "sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==", "dev": true, "license": "MIT", "dependencies": { - "cookie": "^1.0.1", - "set-cookie-parser": "^2.6.0" + "cookie-es": "^3.1.1" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.22.0" }, "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" + "react": ">=19.2.7", + "react-dom": ">=19.2.7" }, "peerDependenciesMeta": { "react-dom": { @@ -9029,23 +9021,6 @@ } } }, - "node_modules/react-router-dom": { - "version": "7.18.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", - "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "react-router": "7.18.1" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - } - }, "node_modules/react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", @@ -9346,13 +9321,6 @@ "node": ">=10" } }, - "node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", - "dev": true, - "license": "MIT" - }, "node_modules/setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", diff --git a/package.json b/package.json index 83ce5ad..60056ce 100644 --- a/package.json +++ b/package.json @@ -39,9 +39,9 @@ "lint-staged": "^17.0.8", "lucide-react": "^1.23.0", "prettier": "^3.6.2", - "react": "^19.2.0", - "react-dom": "^19.2.0", - "react-router-dom": "^7.13.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router": "^8.3.0", "selenium-webdriver": "^4.41.0", "tsx": "^4.20.6", "typescript": "^6.0.3", diff --git a/src/App.tsx b/src/App.tsx index 25e3781..249f8ba 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,7 @@ * Main application component that sets up routing for the Praxly interface. */ -import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; +import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router'; import EditorPage from './pages/EditorPage'; import EmbedPage from './pages/EmbedPage'; import AccountPage from './pages/AccountPage'; diff --git a/src/api/llm.ts b/src/api/llm.ts index 7dbe1d7..559827d 100644 --- a/src/api/llm.ts +++ b/src/api/llm.ts @@ -84,7 +84,13 @@ export async function* streamAssistant(opts: StreamOptions): AsyncGenerator void; - language: SupportedLang; - onLanguageChange?: (lang: SupportedLang) => void; - title: string; - width?: number; - readOnly?: boolean; - editable?: boolean; - resizable?: boolean; - resizeActive?: boolean; - onResize?: (e: React.MouseEvent) => void; - header?: ReactNode; - className?: string; - onCreateEditor?: (view: EditorView) => void; -} - -export const CodeEditorPanel: React.FC = ({ - value, - onChange, - language, - onLanguageChange, - title, - width, - readOnly, - editable = true, - resizable = false, - resizeActive = false, - onResize, - header, - className = '', - onCreateEditor, -}) => { - const containerStyle = width ? { width } : undefined; - - return ( -
-
- {/* Header */} -
-
- {onLanguageChange ? ( - - ) : ( - {language} - )} -
-
- {header} - {title} -
-
- - {/* Editor */} -
- -
-
- - {/* Resize Handle */} - {resizable && onResize && ( - - )} -
- ); -}; diff --git a/src/components/LanguageSelector.tsx b/src/components/LanguageSelector.tsx index 5cea3b9..196cc6b 100644 --- a/src/components/LanguageSelector.tsx +++ b/src/components/LanguageSelector.tsx @@ -1,11 +1,17 @@ /** - * LanguageSelector component that provides a dropdown menu for selecting - * the target programming language (Python, Java, CSP, Praxis, or AST). + * The set of languages the editor knows about, and their display names. + * + * This is the single source of truth for `SupportedLang` — every pane, hook, + * and translation dispatch is typed against it. Note that `ast` and `blocks` + * are *view* languages: both are valid panel targets, but neither is a text + * source with a lexer/parser pair (Blocks' "source" is Blockly workspace JSON, + * and `ast` is a read-only rendering of the parse tree). + * + * The pickers that render these live with their panes: + * - `SOURCE_OPTIONS` in `editor/SourcePane.tsx` — source-language dropdown + * - `PANEL_LANGS` in `editor/AddPanelStrip.tsx` — translation panel rail */ -import React from 'react'; -import { ChevronDown } from 'lucide-react'; - export type SupportedLang = 'python' | 'java' | 'csp' | 'praxis' | 'javascript' | 'blocks' | 'ast'; /** Display names shared by every language picker and dialog. */ @@ -18,41 +24,3 @@ export const LANG_LABELS: Record = { praxis: 'Praxis', python: 'Python', }; - -interface LanguageSelectorProps { - value: SupportedLang; - onChange: (lang: SupportedLang) => void; - includeAst?: boolean; - hideDropdownChevron?: boolean; -} - -export const LanguageSelector: React.FC = ({ - value, - onChange, - includeAst = false, - hideDropdownChevron = false, -}) => { - const languages = includeAst - ? ['ast', 'csp', 'java', 'praxis', 'python'] - : ['csp', 'java', 'praxis', 'python']; - - return ( -
- -
- {languages.map((lang) => ( - - ))} -
-
- ); -}; diff --git a/src/components/TranslationPanel.tsx b/src/components/TranslationPanel.tsx deleted file mode 100644 index e223677..0000000 --- a/src/components/TranslationPanel.tsx +++ /dev/null @@ -1,121 +0,0 @@ -/** - * TranslationPanel component that displays the AST or translated code output. - * Allows toggling between AST view and source-to-target language translation views. - */ - -import React, { useMemo } from 'react'; -import { ArrowRightLeft, FileJson, X } from 'lucide-react'; -import CodeMirror from '@uiw/react-codemirror'; -import { vscodeDark } from '@uiw/codemirror-theme-vscode'; -import { ResizeHandle } from './ResizeHandle'; -import { type SupportedLang } from './LanguageSelector'; -import { getCodeMirrorExtensions, translateCode } from '../utils/editorUtils'; -import { JSONTree } from './JSONTree'; -import type { Program } from '../language/ast'; - -interface TranslationPanelProps { - ast: Program | null; - width: number; - resizeActive: boolean; - onResize: (e: React.MouseEvent) => void; - onClose?: () => void; -} - -type TranslationView = 'ast' | 'translation'; - -export const TranslationPanel: React.FC = ({ - ast, - width, - resizeActive, - onResize, - onClose, -}) => { - const [view, setView] = React.useState('translation'); - const [targetLang, setTargetLang] = React.useState('python'); - - const translation = useMemo(() => { - return translateCode(ast, targetLang); - }, [ast, targetLang]); - - return ( -
-
- {/* Header */} -
-
- {view === 'ast' ? ( - - ) : ( - - )} - - {view === 'ast' ? 'AST' : 'Translation'} - -
-
- - {onClose && ( - - )} -
-
- - {/* Language Selector (only for translation view) */} - {view === 'translation' && ( -
- Translate to: - -
- )} - - {/* Content */} -
- {view === 'ast' ? ( -
- {ast ? ( - - ) : ( -
Valid code required...
- )} -
- ) : ( - - )} -
-
- - {/* Resize Handle */} - -
- ); -}; diff --git a/src/components/account/AccountHeader.tsx b/src/components/account/AccountHeader.tsx new file mode 100644 index 0000000..d28680e --- /dev/null +++ b/src/components/account/AccountHeader.tsx @@ -0,0 +1,38 @@ +import { Link } from 'react-router'; +import { ArrowLeft, LogOut } from 'lucide-react'; + +import { logout } from '../../api/auth'; +import type { AccountProfile } from '../../api/account'; +import { Avatar } from './Avatar'; + +/** Sticky top bar: back to the editor, branding, sign-out, avatar. */ +export function AccountHeader({ profile }: { profile: AccountProfile | null }) { + return ( +
+
+ + + + + + Praxly Account + +
+
+ + +
+
+ ); +} diff --git a/src/components/account/AccountNav.tsx b/src/components/account/AccountNav.tsx new file mode 100644 index 0000000..6d00910 --- /dev/null +++ b/src/components/account/AccountNav.tsx @@ -0,0 +1,63 @@ +import { GraduationCap, KeyRound, MessageSquare, Pencil, ShieldCheck, User } from 'lucide-react'; +import type { Section } from './types'; + +export const NAV: Array<{ id: Section; label: string; icon: typeof User }> = [ + { id: 'home', label: 'Home', icon: User }, + { id: 'personal', label: 'Personal info', icon: Pencil }, + { id: 'security', label: 'Security', icon: ShieldCheck }, + { id: 'ai', label: 'AI model & API key', icon: KeyRound }, + { id: 'profile', label: 'Tutor profile', icon: GraduationCap }, + { id: 'data', label: 'Data & activity', icon: MessageSquare }, +]; + +interface NavProps { + section: Section; + onSelect: (section: Section) => void; +} + +/** Sidebar section switcher (desktop only — see AccountNavMobile). */ +export function AccountNav({ section, onSelect }: NavProps) { + return ( + + ); +} + +/** Horizontally scrolling chip row that replaces the sidebar on narrow screens. */ +export function AccountNavMobile({ section, onSelect }: NavProps) { + return ( +
+ {NAV.map(({ id, label }) => ( + + ))} +
+ ); +} diff --git a/src/components/account/AiSettingsSection.tsx b/src/components/account/AiSettingsSection.tsx new file mode 100644 index 0000000..232734d --- /dev/null +++ b/src/components/account/AiSettingsSection.tsx @@ -0,0 +1,118 @@ +import { useState } from 'react'; +import { Eye, EyeOff } from 'lucide-react'; + +import type { ByokProvider } from '../../store/appStore'; +import { PROVIDER_OPTIONS, useByokDraft } from '../ai/byok'; +import { cardCls, inputCls, primaryBtnCls } from './styles'; +import type { Notify } from './types'; + +/** Bring-your-own-key settings: which model powers the AI Assistant. */ +export function AiSettingsSection({ notify }: { notify: Notify }) { + const draft = useByokDraft(); + const [showKey, setShowKey] = useState(false); + + const handleSave = () => { + if (!draft.save()) return; + notify( + 'success', + draft.draftProvider === '' ? 'Using the school-provided model.' : 'API key saved.' + ); + }; + + return ( +
+
+

AI model & API key

+

+ Pick which model powers the AI Assistant. Using your own API key gives the best experience + — it stays in this browser and is only used to make your requests. The school-provided + model works without a key. +

+
+
+
+ + +
+ + {draft.needsKey && ( + <> +
+ +
+ draft.setDraftKey(e.target.value)} + placeholder="Paste your API key" + autoComplete="off" + spellCheck={false} + className={`${inputCls} pr-10`} + /> + +
+ {draft.hint && ( +

+ Get a key at{' '} + + {draft.hint} + +

+ )} +
+ +
+ + draft.setDraftModel(e.target.value)} + placeholder="Leave blank for the recommended model" + autoComplete="off" + spellCheck={false} + className={inputCls} + /> +
+ + )} + +
+ +
+
+
+ ); +} diff --git a/src/components/account/Avatar.tsx b/src/components/account/Avatar.tsx new file mode 100644 index 0000000..4307efd --- /dev/null +++ b/src/components/account/Avatar.tsx @@ -0,0 +1,26 @@ +import type { AccountProfile } from '../../api/account'; + +/** The user's full name, falling back to their username. */ +export function displayName(profile: AccountProfile | null): string { + if (!profile) return ''; + const full = [profile.firstName, profile.lastName].filter(Boolean).join(' '); + return full || profile.username; +} + +function initialOf(profile: AccountProfile | null): string { + const name = displayName(profile) || profile?.email || '?'; + return name.charAt(0).toUpperCase(); +} + +/** Monogram stand-in for a profile picture (we never fetch one). */ +export function Avatar({ profile, size }: { profile: AccountProfile | null; size: 'sm' | 'lg' }) { + const cls = size === 'lg' ? 'h-20 w-20 text-3xl' : 'h-9 w-9 text-sm'; + return ( + + ); +} diff --git a/src/components/account/ChatHistoryCard.tsx b/src/components/account/ChatHistoryCard.tsx new file mode 100644 index 0000000..4dc6ead --- /dev/null +++ b/src/components/account/ChatHistoryCard.tsx @@ -0,0 +1,107 @@ +import { useState } from 'react'; +import { MessageSquare, Pencil, Trash2 } from 'lucide-react'; + +import { deleteChatApi, renameChatApi, type SessionMeta } from '../../api/chat'; +import { useChatStore } from '../../store/appStore'; +import { cardCls, inputCls } from './styles'; +import type { Notify } from './types'; + +/** Saved AI conversations, with inline rename and delete. */ +export function ChatHistoryCard({ + chats, + setChats, + notify, +}: { + chats: SessionMeta[]; + setChats: (c: SessionMeta[]) => void; + notify: Notify; +}) { + const removeSession = useChatStore((s) => s.removeSession); + const updateSession = useChatStore((s) => s.updateSession); + const [renamingId, setRenamingId] = useState(null); + const [renameValue, setRenameValue] = useState(''); + + const handleDelete = async (id: string) => { + if (!window.confirm('Delete this chat? This cannot be undone.')) return; + try { + await deleteChatApi(id); + removeSession(id); + setChats(chats.filter((c) => c.id !== id)); + notify('success', 'Chat deleted.'); + } catch { + notify('error', 'Failed to delete chat.'); + } + }; + + const handleRename = async (id: string) => { + const title = renameValue.trim(); + setRenamingId(null); + if (!title) return; + try { + await renameChatApi(id, title); + updateSession(id, { title }); + setChats(chats.map((c) => (c.id === id ? { ...c, title } : c))); + } catch { + notify('error', 'Failed to rename chat.'); + } + }; + + return ( +
+
+

Chat history

+

+ Rename or delete your saved conversations with the Praxly tutor. +

+
+ {chats.length === 0 ? ( +

No chats yet.

+ ) : ( +
    + {chats.map((chat) => ( +
  • + + {renamingId === chat.id ? ( + setRenameValue(e.target.value)} + onBlur={() => handleRename(chat.id)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleRename(chat.id); + if (e.key === 'Escape') setRenamingId(null); + }} + className={`${inputCls} max-w-xs py-1`} + /> + ) : ( + + {chat.title ?? 'Untitled chat'} + + )} + + {new Date(chat.updatedAt).toLocaleDateString()} + + + +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/components/account/DataSection.tsx b/src/components/account/DataSection.tsx new file mode 100644 index 0000000..55d1767 --- /dev/null +++ b/src/components/account/DataSection.tsx @@ -0,0 +1,25 @@ +import type { AccountUsage } from '../../api/account'; +import type { SessionMeta } from '../../api/chat'; +import { ChatHistoryCard } from './ChatHistoryCard'; +import { UsageCard } from './UsageCard'; +import type { Notify } from './types'; + +/** Data & activity pane: AI usage stats above the saved chat list. */ +export function DataSection({ + usage, + chats, + setChats, + notify, +}: { + usage: AccountUsage | null; + chats: SessionMeta[]; + setChats: (c: SessionMeta[]) => void; + notify: Notify; +}) { + return ( +
+ + +
+ ); +} diff --git a/src/components/account/HomeSection.tsx b/src/components/account/HomeSection.tsx new file mode 100644 index 0000000..8855bae --- /dev/null +++ b/src/components/account/HomeSection.tsx @@ -0,0 +1,69 @@ +import { KeyRound, MessageSquare, Pencil } from 'lucide-react'; + +import type { AccountProfile, AccountUsage } from '../../api/account'; +import { Avatar, displayName } from './Avatar'; +import { cardCls } from './styles'; +import type { Section } from './types'; + +/** Landing pane: a greeting plus shortcut cards into the other sections. */ +export function HomeSection({ + profile, + usage, + onNavigate, +}: { + profile: AccountProfile | null; + usage: AccountUsage | null; + onNavigate: (s: Section) => void; +}) { + return ( +
+
+ +

+ Welcome, {displayName(profile) || 'student'} +

+

+ Manage your info, security, and chat activity to make Praxly work better for you. +

+
+ +
+ + + +
+
+ ); +} diff --git a/src/components/account/PersonalSection.tsx b/src/components/account/PersonalSection.tsx new file mode 100644 index 0000000..dc1e47e --- /dev/null +++ b/src/components/account/PersonalSection.tsx @@ -0,0 +1,115 @@ +import { useState } from 'react'; + +import { updateProfile, type AccountProfile } from '../../api/account'; +import { cardCls, inputCls, primaryBtnCls } from './styles'; +import type { Notify } from './types'; + +/** Email/name editor. Google-backed accounts get a read-only email. */ +export function PersonalSection({ + profile, + setProfile, + notify, +}: { + profile: AccountProfile | null; + setProfile: (p: AccountProfile) => void; + notify: Notify; +}) { + const [email, setEmail] = useState(profile?.email ?? ''); + const [firstName, setFirstName] = useState(profile?.firstName ?? ''); + const [lastName, setLastName] = useState(profile?.lastName ?? ''); + const [saving, setSaving] = useState(false); + + // Google asserts the email on every sign-in; only Keycloak accounts own theirs. + const emailEditable = profile?.provider !== 'google'; + + const dirty = + (emailEditable && email !== (profile?.email ?? '')) || + firstName !== (profile?.firstName ?? '') || + lastName !== (profile?.lastName ?? ''); + + const handleSave = async () => { + if (!profile) return; + setSaving(true); + try { + await updateProfile({ ...(emailEditable ? { email } : {}), firstName, lastName }); + setProfile({ ...profile, ...(emailEditable ? { email } : {}), firstName, lastName }); + notify('success', 'Profile updated. Changes to your email apply the next time you sign in.'); + } catch (e) { + notify('error', e instanceof Error ? e.message : 'Failed to update profile'); + } finally { + setSaving(false); + } + }; + + return ( +
+
+

Personal info

+

+ The email and name attached to your school account. +

+
+
+
+ + +
+
+ + setEmail(e.target.value)} + disabled={!emailEditable} + className={emailEditable ? inputCls : `${inputCls} text-slate-500 opacity-70`} + placeholder="you@example.com" + /> + {!emailEditable && ( +

+ Your email comes from your Google Account and can't be changed here. +

+ )} +
+
+
+ + setFirstName(e.target.value)} + className={inputCls} + /> +
+
+ + setLastName(e.target.value)} + className={inputCls} + /> +
+
+
+ +
+
+
+ ); +} diff --git a/src/components/account/ProfileSection.tsx b/src/components/account/ProfileSection.tsx new file mode 100644 index 0000000..3d320fb --- /dev/null +++ b/src/components/account/ProfileSection.tsx @@ -0,0 +1,69 @@ +import { useAiPrefsStore, type AiLevel, type AiRole } from '../../store/appStore'; +import { cardCls } from './styles'; + +const ROLE_OPTIONS: Array<{ value: AiRole; label: string; hint: string }> = [ + { value: 'student', label: 'Student', hint: 'Guides you toward answers with hints' }, + { value: 'teacher', label: 'Teacher', hint: 'Direct answers plus teaching tips' }, +]; + +const LEVEL_OPTIONS: Array<{ value: AiLevel; label: string; hint: string }> = [ + { value: 'novice', label: 'Novice', hint: 'New to programming — explain everything' }, + { value: 'intermediate', label: 'Intermediate', hint: 'Knows the basics' }, + { value: 'advanced', label: 'Advanced', hint: 'Comfortable — skip the fundamentals' }, +]; + +/** Who the student is and how much they know — tunes the AI's replies. */ +export function ProfileSection() { + const { role, level, setRole, setLevel } = useAiPrefsStore(); + + const optionBtn = (selected: boolean) => + `w-full text-left rounded-lg border px-3 py-2 transition-colors ${ + selected + ? 'border-indigo-500 bg-indigo-500/10' + : 'border-slate-700 bg-slate-800 hover:border-slate-500' + }`; + + return ( +
+
+

Tutor profile

+

+ The AI adjusts how it responds based on who you are and how much programming you already + know. +

+
+
+
+ I am a… +
+ {ROLE_OPTIONS.map((o) => ( + + ))} +
+
+
+ Experience level +
+ {LEVEL_OPTIONS.map((o) => ( + + ))} +
+
+
+
+ ); +} diff --git a/src/components/account/SecuritySection.tsx b/src/components/account/SecuritySection.tsx new file mode 100644 index 0000000..2098b20 --- /dev/null +++ b/src/components/account/SecuritySection.tsx @@ -0,0 +1,121 @@ +import { useState } from 'react'; + +import { changePassword, type AccountProfile } from '../../api/account'; +import { cardCls, inputCls, primaryBtnCls } from './styles'; +import type { Notify } from './types'; + +/** Password change form for Keycloak accounts; a pointer to Google for the rest. */ +export function SecuritySection({ + profile, + notify, +}: { + profile: AccountProfile | null; + notify: Notify; +}) { + const [current, setCurrent] = useState(''); + const [next, setNext] = useState(''); + const [confirm, setConfirm] = useState(''); + const [saving, setSaving] = useState(false); + + const canSubmit = current.length > 0 && next.length >= 8 && next === confirm && !saving; + + const handleSubmit = async () => { + setSaving(true); + try { + await changePassword(current, next); + setCurrent(''); + setNext(''); + setConfirm(''); + notify('success', 'Password changed.'); + } catch (e) { + notify('error', e instanceof Error ? e.message : 'Failed to change password'); + } finally { + setSaving(false); + } + }; + + // Google owns the credentials for these accounts — there is no password here + // to change, and pretending otherwise would just fail at the backend. + if (profile?.provider === 'google') { + return ( +
+
+

Security

+

+ You sign in to Praxly with Google, so your password is managed by Google. +

+
+ +
+ ); + } + + return ( +
+
+

Security

+

+ Change the password for your school account. It must be at least 8 characters. +

+
+
+
+ + setCurrent(e.target.value)} + className={inputCls} + autoComplete="current-password" + /> +
+
+ + setNext(e.target.value)} + className={inputCls} + autoComplete="new-password" + /> +
+
+ + setConfirm(e.target.value)} + className={inputCls} + autoComplete="new-password" + /> + {confirm.length > 0 && next !== confirm && ( +

Passwords don't match.

+ )} +
+
+ +
+
+
+ ); +} diff --git a/src/components/account/SignedOutCard.tsx b/src/components/account/SignedOutCard.tsx new file mode 100644 index 0000000..ead10bc --- /dev/null +++ b/src/components/account/SignedOutCard.tsx @@ -0,0 +1,22 @@ +import { Link } from 'react-router'; + +import { SignInButtons } from '../auth/SignInButtons'; +import { cardCls, textBtnCls } from './styles'; + +/** Stands in for the whole page when nobody is signed in. */ +export function SignedOutCard() { + return ( +
+
+

Praxly Account

+

Sign in to manage your account.

+
+ +
+ + Back to editor + +
+
+ ); +} diff --git a/src/components/account/StatusBanner.tsx b/src/components/account/StatusBanner.tsx new file mode 100644 index 0000000..660dae2 --- /dev/null +++ b/src/components/account/StatusBanner.tsx @@ -0,0 +1,26 @@ +import { Check, X } from 'lucide-react'; + +export type Status = { kind: 'success' | 'error'; text: string } | null; + +/** Page-level success/error strip shown above whichever section is open. */ +export function StatusBanner({ status, onDismiss }: { status: Status; onDismiss: () => void }) { + if (!status) return null; + return ( +
+ + {status.kind === 'success' ? : } + {status.text} + + +
+ ); +} diff --git a/src/components/account/UsageCard.tsx b/src/components/account/UsageCard.tsx new file mode 100644 index 0000000..70a9daa --- /dev/null +++ b/src/components/account/UsageCard.tsx @@ -0,0 +1,58 @@ +import { useMemo } from 'react'; + +import type { AccountUsage } from '../../api/account'; +import { cardCls } from './styles'; + +/** Message-count totals plus a 30-day activity sparkline. */ +export function UsageCard({ usage }: { usage: AccountUsage | null }) { + // Fill the last 30 days so quiet days render as empty bars, not gaps. + const bars = useMemo(() => { + const byDay = new Map((usage?.daily ?? []).map((d) => [d.day, d.count])); + const days: Array<{ day: string; count: number }> = []; + for (let i = 29; i >= 0; i--) { + const d = new Date(); + d.setDate(d.getDate() - i); + const key = d.toISOString().slice(0, 10); + days.push({ day: key, count: byDay.get(key) ?? 0 }); + } + return days; + }, [usage]); + const maxCount = Math.max(1, ...bars.map((b) => b.count)); + + return ( +
+

AI usage

+
+ {[ + { label: 'Chats', value: usage?.sessions ?? 0 }, + { label: 'Messages', value: usage?.messages ?? 0 }, + { label: 'Sent', value: usage?.sent ?? 0 }, + { label: 'Received', value: usage?.received ?? 0 }, + ].map((stat) => ( +
+
{stat.value}
+
{stat.label}
+
+ ))} +
+ +

+ Last 30 days +

+
+ {bars.map((b) => ( +
0 ? 'bg-indigo-500/100' : 'bg-slate-800'}`} + style={{ height: `${Math.max(4, (b.count / maxCount) * 100)}%` }} + /> + ))} +
+
+ ); +} diff --git a/src/components/account/styles.ts b/src/components/account/styles.ts new file mode 100644 index 0000000..22f55d0 --- /dev/null +++ b/src/components/account/styles.ts @@ -0,0 +1,15 @@ +/** + * Tailwind class strings shared across the account sections, so the cards, + * inputs, and buttons stay visually identical from one pane to the next. + */ + +export const cardCls = 'rounded-2xl border border-slate-800 bg-slate-900'; + +export const inputCls = + 'w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30 transition'; + +export const primaryBtnCls = + 'rounded-full bg-indigo-600 px-5 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-40 disabled:cursor-not-allowed transition-colors'; + +export const textBtnCls = + 'rounded-full px-4 py-2 text-sm font-medium text-indigo-300 hover:bg-slate-800 transition-colors'; diff --git a/src/components/account/types.ts b/src/components/account/types.ts new file mode 100644 index 0000000..61d2f32 --- /dev/null +++ b/src/components/account/types.ts @@ -0,0 +1,7 @@ +/** Shared types for the account manager's sections. */ + +/** Which pane of the account manager is showing. */ +export type Section = 'home' | 'personal' | 'security' | 'ai' | 'profile' | 'data'; + +/** Raises the page-level status banner from inside a section. */ +export type Notify = (kind: 'success' | 'error', text: string) => void; diff --git a/src/components/editor/AiSidePanel.tsx b/src/components/editor/AiSidePanel.tsx index 7798731..e8b41a7 100644 --- a/src/components/editor/AiSidePanel.tsx +++ b/src/components/editor/AiSidePanel.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Link } from 'react-router-dom'; +import { Link } from 'react-router'; import { History, Plus, UserCircle, X } from 'lucide-react'; import type { MouseEvent } from 'react'; import Fuse from 'fuse.js'; diff --git a/src/components/editor/AskAiButton.tsx b/src/components/editor/AskAiButton.tsx new file mode 100644 index 0000000..f3dc6f6 --- /dev/null +++ b/src/components/editor/AskAiButton.tsx @@ -0,0 +1,21 @@ +import { Sparkles } from 'lucide-react'; + +import type { SelectionCoords } from '../../hooks/useAiSelection'; + +/** Floating "Ask AI" button that follows a selection in the source editor. */ +export function AskAiButton({ coords, onClick }: { coords: SelectionCoords; onClick: () => void }) { + return ( + + ); +} diff --git a/src/components/editor/EditorDialogs.tsx b/src/components/editor/EditorDialogs.tsx new file mode 100644 index 0000000..f402f7f --- /dev/null +++ b/src/components/editor/EditorDialogs.tsx @@ -0,0 +1,71 @@ +import { ConfirmModal } from '../ConfirmModal'; +import { LANG_LABELS, type SupportedLang } from '../LanguageSelector'; +import { getExampleById } from '../../utils/sampleCodes'; + +interface EditorDialogsProps { + /** Language the user asked to switch to when the program couldn't be translated. */ + pendingLangSwitch: SupportedLang | null; + /** Example id awaiting confirmation to replace a non-blank editor. */ + pendingExampleId: string | null; + /** True while confirming replacing a non-blank editor with the demo program. */ + pendingDemoLoad: boolean; + onConfirmLangSwitch: (lang: SupportedLang) => void; + onCancelLangSwitch: () => void; + onConfirmExample: (exampleId: string) => void; + onCancelExample: () => void; + onConfirmDemo: () => void; + onCancelDemo: () => void; +} + +/** The editor's three "this will discard your code" confirmations. */ +export function EditorDialogs({ + pendingLangSwitch, + pendingExampleId, + pendingDemoLoad, + onConfirmLangSwitch, + onCancelLangSwitch, + onConfirmExample, + onCancelExample, + onConfirmDemo, + onCancelDemo, +}: EditorDialogsProps) { + return ( + <> + {/* Asks before discarding a program that can't be translated. */} + {pendingLangSwitch && ( + onConfirmLangSwitch(pendingLangSwitch)} + onCancel={onCancelLangSwitch} + /> + )} + + {/* Asks before replacing a non-blank editor with an example program. */} + {pendingExampleId && ( + onConfirmExample(pendingExampleId)} + onCancel={onCancelExample} + /> + )} + + {/* Asks before replacing a non-blank editor with the demo program. */} + {pendingDemoLoad && ( + + )} + + ); +} diff --git a/src/components/editor/EditorHeader.tsx b/src/components/editor/EditorHeader.tsx index 924fa5b..326001f 100644 --- a/src/components/editor/EditorHeader.tsx +++ b/src/components/editor/EditorHeader.tsx @@ -1,4 +1,4 @@ -import { Link } from 'react-router-dom'; +import { Link } from 'react-router'; import { Play, Trash2, diff --git a/src/components/editor/TranslationPanelGrid.tsx b/src/components/editor/TranslationPanelGrid.tsx new file mode 100644 index 0000000..0a02c78 --- /dev/null +++ b/src/components/editor/TranslationPanelGrid.tsx @@ -0,0 +1,154 @@ +import type { DragEvent, MouseEvent } from 'react'; + +import type { Program } from '../../language/ast'; +import type { SupportedLang } from '../LanguageSelector'; +import { MIN_PANEL_WIDTH } from './layoutConstants'; +import { TranslationPaneItem } from './TranslationPaneItem'; +import type { Panel } from './types'; + +interface DragHandlers { + onPanelDragStart: (e: DragEvent, panelId: string) => void; + onPanelDragOver: (e: DragEvent, panelId: string) => void; + onPanelDrop: (e: DragEvent, panelId: string) => void; + onPanelDragEnd: () => void; +} + +interface TranslationPanelGridProps { + panels: Panel[]; + rootPanels: Panel[]; + /** The column that stretches to fill the row's leftover width. */ + elasticPanelId: string | null; + isMobile: boolean; + ast: Program | null; + draggedPanelId: string | null; + dragOverPanelId: string | null; + getTranslationCode: (lang: SupportedLang) => string; + fontSize: number; + panelHighlightedLines: Map; + showMemDia: boolean; + memDiaResizingPaneId: string | null; + getMemDiaHeight: (paneId: string) => number; + getMemDiaState: (paneId: string) => 'open' | 'closed'; + onToggleMemDia: (paneId: string) => void; + onMemDiaResizeMouseDown: (e: MouseEvent, paneId: string) => void; + currentVariables: Record; + resizingIdx: number | 'editor' | 'output' | null; + onResizeColumn: (e: MouseEvent, panelIndex: number) => void; + onRemovePanel: (id: string) => void; + onToggleStack: (id: string) => void; + dragHandlers: DragHandlers; +} + +/** + * The row of translation panes. Each root panel is a column that may hold a + * second panel stacked beneath it; the last column stretches to fill whatever + * width the others leave over. + */ +export function TranslationPanelGrid({ + panels, + rootPanels, + elasticPanelId, + isMobile, + ast, + draggedPanelId, + dragOverPanelId, + getTranslationCode, + fontSize, + panelHighlightedLines, + showMemDia, + memDiaResizingPaneId, + getMemDiaHeight, + getMemDiaState, + onToggleMemDia, + onMemDiaResizeMouseDown, + currentVariables, + resizingIdx, + onResizeColumn, + onRemovePanel, + onToggleStack, + dragHandlers, +}: TranslationPanelGridProps) { + return ( + <> + {rootPanels.map((rootPanel) => { + const rootIdx = panels.indexOf(rootPanel); + const stackedPanel = panels.find((p) => p.stackedUnder === rootPanel.id); + // The last column fills the leftover width instead of taking a fixed + // one, so there is nothing to its right to resize against. (On mobile + // the row scrolls horizontally, so it stays fixed.) + const isElastic = rootPanel.id === elasticPanelId && !isMobile; + const onResize = isElastic ? undefined : (e: MouseEvent) => onResizeColumn(e, rootIdx); + + // "Stack below" is offered on a root panel when its column isn't full + // and there is another free root it could move under. + const hasFreeNeighbour = panels.some( + (p) => + !p.stackedUnder && + p.id !== rootPanel.id && + !panels.some((child) => child.stackedUnder === p.id) + ); + const canStack = !stackedPanel && hasFreeNeighbour; + + const sharedProps = { + ast, + draggedPanelId, + dragOverPanelId, + fontSize, + showMemDia, + resizingMemDiaPaneId: memDiaResizingPaneId, + currentVariables, + resizeActive: resizingIdx === rootIdx, + onRemovePanel, + onResize, + onMemDiaResizeMouseDown, + ...dragHandlers, + }; + + return ( +
+
+ onToggleMemDia(rootPanel.id)} + onToggleStack={canStack ? () => onToggleStack(rootPanel.id) : undefined} + isStacked={false} + /> +
+ + {/* Stacked child — same flex-1 height, shares the column's width */} + {stackedPanel && ( +
+ onToggleMemDia(stackedPanel.id)} + onToggleStack={() => onToggleStack(stackedPanel.id)} + isStacked={true} + /> +
+ )} +
+ ); + })} + + ); +} diff --git a/src/components/editor/layoutConstants.ts b/src/components/editor/layoutConstants.ts new file mode 100644 index 0000000..8de4976 --- /dev/null +++ b/src/components/editor/layoutConstants.ts @@ -0,0 +1,22 @@ +/** Pixel budgets shared by the editor's panes and their resize drags. */ + +export const MIN_SOURCE_WIDTH = 280; +export const MIN_PANEL_WIDTH = 240; +export const MIN_OUTPUT_HEIGHT = 120; +export const MIN_MEM_DIA_HEIGHT = 100; +export const MAX_MEM_DIA_HEIGHT = 360; +export const MIN_AI_PANEL_WIDTH = 260; +export const MAX_AI_PANEL_WIDTH = 560; +/** Fixed width of the left icon rail (`AddPanelStrip`). */ +export const ADD_STRIP_WIDTH = 64; +/** Below this the pane row scrolls horizontally instead of sharing the width. */ +export const MOBILE_BREAKPOINT = 1024; + +export const clamp = (value: number, min: number, max: number): number => + Math.min(Math.max(value, min), max); + +/** Width a freshly opened translation panel starts at. */ +export const defaultPanelWidth = (viewportWidth: number = window.innerWidth): number => + viewportWidth < MOBILE_BREAKPOINT + ? Math.max(MIN_PANEL_WIDTH, Math.floor(viewportWidth * 0.8)) + : 350; diff --git a/src/components/embed/EmbedActions.tsx b/src/components/embed/EmbedActions.tsx new file mode 100644 index 0000000..cc6534d --- /dev/null +++ b/src/components/embed/EmbedActions.tsx @@ -0,0 +1,71 @@ +import { Play, FastForward, Square } from 'lucide-react'; + +interface EmbedActionsProps { + isDebugging: boolean; + isDebugComplete: boolean; + onRun: () => void; + onDebugStart: () => void; + onDebugStep: () => void; + onDebugStop: () => void; + onOpenInEditor: () => void; +} + +/** Run/Debug controls — shown in both embed layouts, so they live here once. */ +export function EmbedActions({ + isDebugging, + isDebugComplete, + onRun, + onDebugStart, + onDebugStep, + onDebugStop, + onOpenInEditor, +}: EmbedActionsProps) { + return ( +
+ {!isDebugging ? ( + <> + + + + ) : ( + <> + + + + )} + +
+ ); +} diff --git a/src/components/embed/EmbedErrorScreen.tsx b/src/components/embed/EmbedErrorScreen.tsx new file mode 100644 index 0000000..61f317c --- /dev/null +++ b/src/components/embed/EmbedErrorScreen.tsx @@ -0,0 +1,16 @@ +import { AlertCircle } from 'lucide-react'; + +/** Shown instead of the player when the link carries no decodable program. */ +export function EmbedErrorScreen({ message }: { message: string | null }) { + return ( +
+
+ +
+

{message || 'No Code Found'}

+

The embed data could not be loaded.

+
+
+
+ ); +} diff --git a/src/components/embed/EmbedOutput.tsx b/src/components/embed/EmbedOutput.tsx new file mode 100644 index 0000000..5e42a6b --- /dev/null +++ b/src/components/embed/EmbedOutput.tsx @@ -0,0 +1,70 @@ +import type { StackFrame } from '../../language/debugger'; +import { VariableFrames } from '../VariableFrames'; +import { StdinPrompt } from './StdinPrompt'; + +interface EmbedOutputProps { + output: string[]; + error: string | null; + isDebugging: boolean; + callStack: StackFrame[]; + waitingForInput: boolean; + inputPrompt: string; + onSubmitInput: (input: string) => void; +} + +/** Console body: error banner, debug variables, output lines, stdin prompt. */ +export function EmbedOutput({ + output, + error, + isDebugging, + callStack, + waitingForInput, + inputPrompt, + onSubmitInput, +}: EmbedOutputProps) { + return ( +
+
+ {error && ( +
+ Error: {error} +
+ )} + {isDebugging && callStack.length > 0 && ( +
+
+ Variables + {callStack.length > 1 && ( + + — in {callStack[callStack.length - 1].name}() + + )} +
+ +
+ )} + {output.length === 0 && !error ? ( +
+ {isDebugging ? 'No output yet — keep stepping…' : 'Run code to see output...'} +
+ ) : ( + output.map((line, idx) => ( +
+ + {line} +
+ )) + )} +
+ {waitingForInput && } +
+ ); +} diff --git a/src/components/embed/EmbedSourcePane.tsx b/src/components/embed/EmbedSourcePane.tsx new file mode 100644 index 0000000..6ba71b2 --- /dev/null +++ b/src/components/embed/EmbedSourcePane.tsx @@ -0,0 +1,65 @@ +import type { MouseEvent } from 'react'; +import CodeMirror from '@uiw/react-codemirror'; +import { vscodeDark } from '@uiw/codemirror-theme-vscode'; +import { EditorView } from '@codemirror/view'; +import type { Extension } from '@codemirror/state'; + +import type { SupportedLang } from '../LanguageSelector'; + +interface EmbedSourcePaneProps { + lang: SupportedLang; + code: string; + width: number; + extensions: Extension[]; + resizeActive: boolean; + onCodeChange: (value: string) => void; + onCreateEditor: (view: EditorView) => void; + onStartResize: (e: MouseEvent) => void; +} + +/** Editable source column, identical in both embed layouts. */ +export function EmbedSourcePane({ + lang, + code, + width, + extensions, + resizeActive, + onCodeChange, + onCreateEditor, + onStartResize, +}: EmbedSourcePaneProps) { + return ( +
+
+
+ Source ({lang}) +
+
+ +
+
+ + ); +} diff --git a/src/components/embed/EmbedTranslationPane.tsx b/src/components/embed/EmbedTranslationPane.tsx new file mode 100644 index 0000000..45f2780 --- /dev/null +++ b/src/components/embed/EmbedTranslationPane.tsx @@ -0,0 +1,119 @@ +import type { ReactNode } from 'react'; +import { ChevronDown } from 'lucide-react'; + +import type { Program } from '../../language/ast'; +import type { SupportedLang } from '../LanguageSelector'; +import { HighlightableCodeMirror } from '../HighlightableCodeMirror'; +import { JSONTree } from '../JSONTree'; + +/** Languages the embed's translation pane can switch between. */ +const TARGET_OPTIONS: SupportedLang[] = ['python', 'java', 'csp', 'praxis']; + +interface EmbedTranslationPaneProps { + ast: Program | null; + translationCode: string; + targetLang: SupportedLang; + showAst: boolean; + menuOpen: boolean; + highlightedLines: number[]; + /** Run/Debug controls, rendered under this pane's header. */ + actions: ReactNode; + onToggleMenu: () => void; + onSelectTarget: (lang: SupportedLang) => void; + onSelectAst: () => void; +} + +/** Read-only translation (or AST) column, shown for `?to=` embed links. */ +export function EmbedTranslationPane({ + ast, + translationCode, + targetLang, + showAst, + menuOpen, + highlightedLines, + actions, + onToggleMenu, + onSelectTarget, + onSelectAst, +}: EmbedTranslationPaneProps) { + return ( +
+
+
+ {/* Header with language selector */} +
+
+ + {menuOpen && ( +
+ {TARGET_OPTIONS.map((lang) => ( + + ))} +
+ +
+ )} +
+
+ +
+ {actions} +
+
+ +
+ {showAst ? ( +
+ {ast ? ( + + ) : ( +
+ Valid code required... +
+ )} +
+ ) : ( + + )} +
+
+
+ ); +} diff --git a/src/components/embed/StdinPrompt.tsx b/src/components/embed/StdinPrompt.tsx new file mode 100644 index 0000000..000a5ef --- /dev/null +++ b/src/components/embed/StdinPrompt.tsx @@ -0,0 +1,50 @@ +import { useId } from 'react'; + +/** + * Input row shown while a program is parked on an `input()` call. The field is + * uncontrolled — it is cleared by hand after each submit so a fast typist can + * keep going without waiting for a re-render. + */ +export function StdinPrompt({ + prompt, + onSubmit, +}: { + prompt: string; + onSubmit: (input: string) => void; +}) { + const inputId = useId(); + + const submitFrom = (input: HTMLInputElement) => { + onSubmit(input.value); + input.value = ''; + }; + + return ( +
+ +
+ { + if (e.key === 'Enter') submitFrom(e.target as HTMLInputElement); + }} + className="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded text-slate-100 text-xs font-mono placeholder:text-slate-400 focus:outline-none focus:border-indigo-400 focus:ring-1 focus:ring-indigo-400" + data-testid="stdin-input" + autoFocus + /> + +
+
+ ); +} diff --git a/src/hooks/useAccountData.ts b/src/hooks/useAccountData.ts new file mode 100644 index 0000000..077f5e9 --- /dev/null +++ b/src/hooks/useAccountData.ts @@ -0,0 +1,52 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { isAuthenticated } from '../api/auth'; +import { getAccount, getUsage, type AccountProfile, type AccountUsage } from '../api/account'; +import { listChats, type SessionMeta } from '../api/chat'; +import type { Status } from '../components/account/StatusBanner'; + +/** + * Loads everything the account manager shows (profile, usage, chat list) in one + * pass, and owns the status banner the sections write to. The three fetches are + * settled independently so one failing backend route doesn't blank the page. + */ +export function useAccountData() { + const [profile, setProfile] = useState(null); + const [usage, setUsage] = useState(null); + const [chats, setChats] = useState([]); + const [loading, setLoading] = useState(true); + const [status, setStatus] = useState(null); + + const notify = useCallback((kind: 'success' | 'error', text: string) => { + setStatus({ kind, text }); + }, []); + + const dismissStatus = useCallback(() => setStatus(null), []); + + useEffect(() => { + if (!isAuthenticated()) { + setLoading(false); + return; + } + Promise.allSettled([getAccount(), getUsage(), listChats()]).then( + ([profileRes, usageRes, chatsRes]) => { + if (profileRes.status === 'fulfilled') setProfile(profileRes.value); + if (usageRes.status === 'fulfilled') setUsage(usageRes.value); + if (chatsRes.status === 'fulfilled') setChats(chatsRes.value); + setLoading(false); + } + ); + }, []); + + return { + profile, + setProfile, + usage, + chats, + setChats, + loading, + status, + notify, + dismissStatus, + }; +} diff --git a/src/hooks/useAiSelection.ts b/src/hooks/useAiSelection.ts new file mode 100644 index 0000000..dc73168 --- /dev/null +++ b/src/hooks/useAiSelection.ts @@ -0,0 +1,40 @@ +/** + * Highlight-to-chat: tracks what the user has selected in the source editor and + * where to float the "Ask AI" button, so the selection can be sent to the AI + * panel as context. + */ + +import { useCallback, useState } from 'react'; +import type { ViewUpdate } from '@codemirror/view'; + +export interface SelectionCoords { + top: number; + left: number; +} + +export function useAiSelection() { + const [selection, setSelection] = useState(''); + const [coords, setCoords] = useState(null); + + const onEditorUpdate = useCallback((update: ViewUpdate) => { + if (!update.selectionSet && !update.docChanged && !update.geometryChanged) return; + + const sel = update.state.selection.main; + if (sel.empty) { + setSelection(''); + setCoords(null); + return; + } + + setSelection(update.state.sliceDoc(sel.from, sel.to)); + const pos = update.view.coordsAtPos(sel.to); + setCoords(pos ? { top: pos.bottom + 4, left: pos.left } : null); + }, []); + + const clear = useCallback(() => { + setSelection(''); + setCoords(null); + }, []); + + return { selection, coords, onEditorUpdate, clear }; +} diff --git a/src/hooks/useDragWidth.ts b/src/hooks/useDragWidth.ts new file mode 100644 index 0000000..371eb99 --- /dev/null +++ b/src/hooks/useDragWidth.ts @@ -0,0 +1,38 @@ +/** + * Drag-to-resize width for a single pane, driven by raw pointer deltas. + * + * Deltas (rather than an absolute anchor) are enough here because there is only + * one divider on the page; the multi-pane editor needs the anchored maths in + * `useEditorLayout` instead. + */ + +import { useEffect, useState } from 'react'; +import type { MouseEvent } from 'react'; + +export function useDragWidth(initialWidth: number, minWidth: number) { + const [width, setWidth] = useState(initialWidth); + const [isResizing, setIsResizing] = useState(false); + + const startResize = (e: MouseEvent) => { + e.preventDefault(); + setIsResizing(true); + }; + + useEffect(() => { + if (!isResizing) return; + + const handleMouseMove = (e: globalThis.MouseEvent) => { + setWidth((prev) => Math.max(minWidth, prev + e.movementX)); + }; + const handleMouseUp = () => setIsResizing(false); + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + }, [isResizing, minWidth]); + + return { width, isResizing, startResize }; +} diff --git a/src/hooks/useEditorExecution.ts b/src/hooks/useEditorExecution.ts new file mode 100644 index 0000000..b249c67 --- /dev/null +++ b/src/hooks/useEditorExecution.ts @@ -0,0 +1,251 @@ +/** + * Everything the editor *does* with the program: parsing it as the user types, + * running it, stepping the debugger, and keeping the console and the per-pane + * highlighting in sync. `EditorPage` is left to wire this to the layout. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; + +import type { Program } from '../language/ast'; +import type { SupportedLang } from '../components/LanguageSelector'; +import type { Panel } from '../components/editor/types'; +import { computeMultiplePanelHighlighting } from '../utils/debugHandlers'; +import { useCodeDebugger } from './useCodeDebugger'; +import { useProgramRunner } from './useProgramRunner'; +import type { SourceMap } from './useCodeParsing'; + +type ParseCode = (lang: SupportedLang, input: string) => Program | null; +type GetTranslation = ( + ast: Program | null, + lang: SupportedLang +) => { code: string; sourceMap: SourceMap }; + +interface UseEditorExecutionArgs { + code: string; + sourceLang: SupportedLang; + panels: Panel[]; + parseCode: ParseCode; + getTranslation: GetTranslation; +} + +/** The AST view can't be executed — run its programs as Python. */ +const executableLang = (lang: SupportedLang): SupportedLang => (lang === 'ast' ? 'python' : lang); + +export function useEditorExecution({ + code, + sourceLang, + panels, + parseCode, + getTranslation, +}: UseEditorExecutionArgs) { + const [ast, setAst] = useState(null); + const [output, setOutput] = useState([]); + const [error, setError] = useState(null); + // Once true, the "Run code to see execution results..." placeholder never + // reappears — it's a first-run hint, not a state to flash on every re-run. + const [hasRun, setHasRun] = useState(false); + const [panelHighlightedLines, setPanelHighlightedLines] = useState>( + new Map() + ); + + const { + isDebugging, + setIsDebugging, + isDebugComplete, + setIsDebugComplete, + highlightedSourceLines, + setHighlightedSourceLines, + currentVariables, + currentCallStack, + waitingForInput, + inputPrompt, + initDebugger, + stepDebugger, + stopDebugger, + provideInput, + } = useCodeDebugger(getTranslation); + + const reportError = useCallback((message: string) => { + setError(message); + setOutput((prev) => [...prev, `Error: ${message}`]); + }, []); + + const runner = useProgramRunner({ onOutput: setOutput, onError: reportError }); + + // Running is deferred a beat so the cleared console paints first. + const runTimeoutRef = useRef(null); + useEffect(() => { + return () => { + if (runTimeoutRef.current !== null) clearTimeout(runTimeoutRef.current); + }; + }, []); + + const runLang = executableLang(sourceLang); + + // Re-parse on every edit so the translation panes and the AST view stay live. + useEffect(() => { + if (sourceLang === 'ast') return; + + try { + setAst(parseCode(sourceLang, code)); + setError(null); + } catch (e: any) { + setAst(null); + setError(e.message); + } + }, [code, sourceLang, parseCode]); + + const clearHighlighting = useCallback(() => { + setHighlightedSourceLines([]); + setPanelHighlightedLines(new Map()); + }, [setHighlightedSourceLines]); + + /** Empties the console. Pass `resetHasRun` to bring the first-run hint back. */ + const clearConsole = useCallback((options: { resetHasRun?: boolean } = {}) => { + setOutput([]); + setError(null); + if (options.resetHasRun) setHasRun(false); + }, []); + + const resetRunner = runner.reset; + + /** Abandons any in-flight run or debug session and its highlighting. */ + const stopSession = useCallback(() => { + setIsDebugging(false); + setIsDebugComplete(false); + clearHighlighting(); + resetRunner(); + }, [setIsDebugging, setIsDebugComplete, clearHighlighting, resetRunner]); + + /** Applies one debugger step to the source, the panes, and the console. */ + const applyDebugStep = useCallback( + (program: Program) => { + const result = stepDebugger(program, code, runLang); + if (!result) return; + + setHighlightedSourceLines(result.sourceHighlightedLines); + setPanelHighlightedLines( + computeMultiplePanelHighlighting(program, panels, getTranslation, result.step?.nodeId) + ); + // outputLines is the cumulative program output, so replace rather than append. + setOutput(result.outputLines); + + if (result.isComplete) { + setIsDebugComplete(true); + setOutput((prev) => [...prev, 'Execution complete.']); + } + }, + [ + code, + runLang, + panels, + getTranslation, + stepDebugger, + setHighlightedSourceLines, + setIsDebugComplete, + ] + ); + + const run = () => { + clearConsole(); + setHasRun(true); + runner.reset(); + + if (runTimeoutRef.current !== null) clearTimeout(runTimeoutRef.current); + runTimeoutRef.current = window.setTimeout(() => { + runTimeoutRef.current = null; + try { + const program = parseCode(runLang, code); + if (!program) return; + + setAst(program); + runner.run(program, runLang, code); + } catch (e: any) { + console.error(e); + reportError(e.message); + } + }, 100); + }; + + const debugStart = () => { + clearConsole(); + setHasRun(true); + + try { + const program = parseCode(runLang, code); + if (!program) return; + + setAst(program); + initDebugger(program, runLang, code); + setOutput(['Debugger initialized. Click Step to begin.']); + clearHighlighting(); + } catch (e: any) { + console.error(e); + setError(e.message); + } + }; + + const debugStep = () => { + if (!ast) return; + + try { + applyDebugStep(ast); + } catch (e: any) { + console.error(e); + reportError(e.message); + setIsDebugComplete(true); + } + }; + + const debugStop = () => { + stopDebugger(); + setIsDebugging(false); + clearHighlighting(); + setOutput((prev) => [...prev, 'Debugger stopped.']); + }; + + /** Feeds a paused `input()` call — a plain run's, or the debugger's. */ + const submitInput = (input: string) => { + if (runner.waitingForInput) { + runner.submitInput(input); + return; + } + + provideInput(input); + if (!ast) return; + // Let the debugger hook flush its state before stepping past the pause. + setTimeout(() => applyDebugStep(ast), 0); + }; + + /** Drops the editor's highlighting while the program is being edited mid-debug. */ + const noteCodeEdited = useCallback(() => { + if (isDebugging) clearHighlighting(); + }, [isDebugging, clearHighlighting]); + + return { + ast, + setAst, + error, + setError, + output, + hasRun, + panelHighlightedLines, + isDebugging, + isDebugComplete, + currentVariables, + currentCallStack, + highlightedSourceLines, + waitingForInput: waitingForInput || runner.waitingForInput, + inputPrompt: inputPrompt || runner.inputPrompt, + run, + debugStart, + debugStep, + debugStop, + submitInput, + clearConsole, + stopSession, + noteCodeEdited, + }; +} + +export type EditorExecution = ReturnType; diff --git a/src/hooks/useEditorLayout.ts b/src/hooks/useEditorLayout.ts new file mode 100644 index 0000000..0705de2 --- /dev/null +++ b/src/hooks/useEditorLayout.ts @@ -0,0 +1,319 @@ +/** + * Sizing for the editor's pane row: how wide the source and translation panes + * are, how tall the console is, how wide the AI side panel is, and which + * divider (if any) is currently being dragged. + * + * Widths are recomputed from a single budget — the viewport minus the icon rail + * and the AI panel — so the row always fills exactly, with the last column + * stretching to absorb any rounding remainder. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { Dispatch, MouseEvent, SetStateAction } from 'react'; + +import type { Panel } from '../components/editor/types'; +import { + ADD_STRIP_WIDTH, + MAX_AI_PANEL_WIDTH, + MIN_AI_PANEL_WIDTH, + MIN_OUTPUT_HEIGHT, + MIN_PANEL_WIDTH, + MIN_SOURCE_WIDTH, + MOBILE_BREAKPOINT, + clamp, +} from '../components/editor/layoutConstants'; + +/** Which divider a drag is moving: a panel by index, the source pane, or the console. */ +export type ResizeTarget = number | 'editor' | 'output'; + +type ResizeDragSnapshot = { + index: ResizeTarget; + startX: number; + startY: number; + startEditorWidth: number; + startPanelWidth: number; + startOutputHeight: number; + /** Widest the dragged pane may get before it squeezes the elastic + * last column under `MIN_PANEL_WIDTH`. Fixed for the whole drag — + * only the dragged pane's own width changes. */ + maxPaneWidth: number; +}; + +interface UseEditorLayoutArgs { + panels: Panel[]; + setPanels: Dispatch>; + rootPanels: Panel[]; + showAiSidePanel: boolean; +} + +export function useEditorLayout({ + panels, + setPanels, + rootPanels, + showAiSidePanel, +}: UseEditorLayoutArgs) { + const [viewportWidth, setViewportWidth] = useState(window.innerWidth); + const [editorWidth, setEditorWidth] = useState( + Math.max(MIN_SOURCE_WIDTH, Math.floor(window.innerWidth * 0.45)) + ); + const [outputHeight, setOutputHeight] = useState( + Math.max(176, Math.floor(window.innerHeight * 0.24)) + ); + const [outputState, setOutputState] = useState<'open' | 'closed'>('open'); + const [aiPanelWidth, setAiPanelWidth] = useState(320); + const [isResizingAiPanel, setIsResizingAiPanel] = useState(false); + const [resizingIdx, setResizingIdx] = useState(null); + + const resizeDragRef = useRef(null); + const aiResizeRef = useRef<{ startX: number; startWidth: number } | null>(null); + + const isMobile = viewportWidth < MOBILE_BREAKPOINT; + const rootPanelCount = rootPanels.length; + + /** Room the source + translation panes share, after the fixed chrome. */ + const getContentAvailableWidth = useCallback( + () => + Math.max( + MIN_SOURCE_WIDTH, + viewportWidth - ADD_STRIP_WIDTH - (showAiSidePanel ? aiPanelWidth : 0) + ), + [aiPanelWidth, showAiSidePanel, viewportWidth] + ); + + const getMaxSourceWidth = useCallback(() => { + const reservedWidth = + ADD_STRIP_WIDTH + + (showAiSidePanel ? aiPanelWidth : 0) + + (panels.length > 0 ? MIN_PANEL_WIDTH : 0); + + return Math.max(MIN_SOURCE_WIDTH, viewportWidth - reservedWidth); + }, [aiPanelWidth, panels.length, showAiSidePanel, viewportWidth]); + + const maxAiPanelWidth = useCallback( + () => + Math.min( + MAX_AI_PANEL_WIDTH, + Math.max(MIN_AI_PANEL_WIDTH, viewportWidth - MIN_SOURCE_WIDTH - ADD_STRIP_WIDTH - 40) + ), + [viewportWidth] + ); + + useEffect(() => { + const handleResize = () => setViewportWidth(window.innerWidth); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + // Keep every stored size inside its bounds as the viewport (or the AI panel) + // changes what there is to share. + useEffect(() => { + const maxSourceWidth = getMaxSourceWidth(); + const maxOutputHeight = Math.max(MIN_OUTPUT_HEIGHT, window.innerHeight - 120); + const maxAiWidth = maxAiPanelWidth(); + + setEditorWidth((prev) => clamp(prev, MIN_SOURCE_WIDTH, maxSourceWidth)); + setOutputHeight((prev) => clamp(prev, MIN_OUTPUT_HEIGHT, maxOutputHeight)); + setAiPanelWidth((prev) => clamp(prev, MIN_AI_PANEL_WIDTH, maxAiWidth)); + setPanels((prev) => { + let changed = false; + const next = prev.map((panel) => { + const clampedWidth = Math.max(MIN_PANEL_WIDTH, panel.width); + if (clampedWidth !== panel.width) { + changed = true; + return { ...panel, width: clampedWidth }; + } + return panel; + }); + + return changed ? next : prev; + }); + }, [getMaxSourceWidth, maxAiPanelWidth, setPanels, viewportWidth]); + + // Re-split the row evenly when the number of side-by-side panes changes, or + // when the space they share does. Keyed on the pane *count*, not on `panels` + // itself: that array gets rebuilt on every keystroke (source maps are + // refreshed per AST), and depending on it here undid each resize drag the + // frame after it happened. + useEffect(() => { + const availableWidth = getContentAvailableWidth(); + + if (rootPanelCount === 0) { + setEditorWidth((prev) => (prev === availableWidth ? prev : availableWidth)); + return; + } + + const totalPaneCount = rootPanelCount + 1; + const targetPanelWidth = Math.max(MIN_PANEL_WIDTH, Math.floor(availableWidth / totalPaneCount)); + // The source pane absorbs the rounding remainder so the row fills exactly. + const targetSourceWidth = Math.max( + MIN_SOURCE_WIDTH, + availableWidth - targetPanelWidth * rootPanelCount + ); + + setEditorWidth((prev) => (prev === targetSourceWidth ? prev : targetSourceWidth)); + + setPanels((prev) => { + let changed = false; + const next = prev.map((panel) => { + if (panel.width === targetPanelWidth) return panel; + changed = true; + // Stacked panels are sized too: they render at their column's width, so + // an un-synced one would jump if it later swapped places with its root. + return { ...panel, width: targetPanelWidth }; + }); + + return changed ? next : prev; + }); + }, [getContentAvailableWidth, rootPanelCount, setPanels]); + + const startPaneResize = (e: MouseEvent, index: ResizeTarget) => { + e.preventDefault(); + + if (index === 'editor' && panels.length === 0) return; + if (typeof index === 'number' && !panels[index]) return; + + // Everything the dragged pane can't claim: the other fixed-width columns, + // the source pane (when a panel is being dragged), and the minimum the + // elastic last column has to keep. + const draggedId = typeof index === 'number' ? panels[index].id : null; + const otherFixedWidth = rootPanels + .slice(0, -1) + .filter((panel) => panel.id !== draggedId) + .reduce((sum, panel) => sum + panel.width, 0); + + resizeDragRef.current = { + index, + startX: e.clientX, + startY: e.clientY, + startEditorWidth: editorWidth, + startPanelWidth: typeof index === 'number' ? panels[index].width : 0, + startOutputHeight: outputHeight, + maxPaneWidth: + getContentAvailableWidth() - + otherFixedWidth - + MIN_PANEL_WIDTH - + (index === 'editor' ? 0 : editorWidth), + }; + + setResizingIdx(index); + }; + + useEffect(() => { + if (resizingIdx === null) return; + + const handleMouseMove = (e: globalThis.MouseEvent) => { + const drag = resizeDragRef.current; + if (!drag) return; + + if (resizingIdx === 'output') { + const deltaY = e.clientY - drag.startY; + const maxOutputHeight = Math.max(MIN_OUTPUT_HEIGHT, window.innerHeight - 120); + const rawHeight = drag.startOutputHeight - deltaY; + // Dragged well past its minimum, the console collapses rather than + // sticking at the smallest open size. + if (rawHeight < MIN_OUTPUT_HEIGHT - 40) { + setOutputState('closed'); + } else { + setOutputHeight(clamp(rawHeight, MIN_OUTPUT_HEIGHT, maxOutputHeight)); + setOutputState('open'); + } + } else if (resizingIdx === 'editor') { + const deltaX = e.clientX - drag.startX; + setEditorWidth( + clamp( + drag.startEditorWidth + deltaX, + MIN_SOURCE_WIDTH, + Math.max(MIN_SOURCE_WIDTH, drag.maxPaneWidth) + ) + ); + } else { + const panelIndex = resizingIdx; + setPanels((prev) => { + if (!prev[panelIndex]) return prev; + + const deltaX = e.clientX - drag.startX; + const newWidth = clamp( + drag.startPanelWidth + deltaX, + MIN_PANEL_WIDTH, + Math.max(MIN_PANEL_WIDTH, drag.maxPaneWidth) + ); + if (prev[panelIndex].width === newWidth) return prev; + + const next = [...prev]; + next[panelIndex] = { ...prev[panelIndex], width: newWidth }; + return next; + }); + } + }; + + const handleMouseUp = () => { + setResizingIdx(null); + resizeDragRef.current = null; + }; + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + }, [resizingIdx, setPanels]); + + // Shared by both AI-panel resize handles (the panel's left edge and the + // left edge of the add-panel strip): they move in lockstep because the + // strip has a fixed width, so one drag math works for both. + const startAiResize = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + aiResizeRef.current = { startX: e.clientX, startWidth: aiPanelWidth }; + setIsResizingAiPanel(true); + }, + [aiPanelWidth] + ); + + useEffect(() => { + if (!isResizingAiPanel) return; + + const handleMouseMove = (e: globalThis.MouseEvent) => { + const drag = aiResizeRef.current; + if (!drag) return; + + // The panel grows leftward, so a leftward drag (negative delta) widens it. + const deltaX = e.clientX - drag.startX; + setAiPanelWidth(clamp(drag.startWidth - deltaX, MIN_AI_PANEL_WIDTH, maxAiPanelWidth())); + }; + + const handleMouseUp = () => { + aiResizeRef.current = null; + setIsResizingAiPanel(false); + }; + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + }, [isResizingAiPanel, maxAiPanelWidth]); + + const toggleOutputState = useCallback(() => { + setOutputState((prev) => (prev === 'open' ? 'closed' : 'open')); + }, []); + + const openOutput = useCallback(() => setOutputState('open'), []); + + return { + isMobile, + /** With no translation panels open the source pane takes the whole row. */ + sourcePaneWidth: panels.length === 0 ? getContentAvailableWidth() : editorWidth, + outputHeight, + outputState, + toggleOutputState, + openOutput, + aiPanelWidth, + isResizingAiPanel, + startAiResize, + resizingIdx, + startPaneResize, + }; +} diff --git a/src/hooks/useEditorMenus.ts b/src/hooks/useEditorMenus.ts new file mode 100644 index 0000000..0b00792 --- /dev/null +++ b/src/hooks/useEditorMenus.ts @@ -0,0 +1,54 @@ +/** + * The editor header's three dropdowns. Only one of the header menus is open at + * a time, and each closes when the user clicks anywhere outside it. + */ + +import { useCallback, useState } from 'react'; + +import { useClickOutside } from './useClickOutside'; + +export function useEditorMenus() { + const [showSourceLangDropdown, setShowSourceLangDropdown] = useState(false); + const [showExamplesMenu, setShowExamplesMenu] = useState(false); + const [showSettingsMenu, setShowSettingsMenu] = useState(false); + + useClickOutside(showSourceLangDropdown, '.source-lang-dropdown', () => + setShowSourceLangDropdown(false) + ); + useClickOutside(showSettingsMenu, '.settings-dropdown', () => setShowSettingsMenu(false)); + useClickOutside(showExamplesMenu, '.examples-dropdown', () => setShowExamplesMenu(false)); + + const toggleSourceLangDropdown = useCallback( + () => setShowSourceLangDropdown((prev) => !prev), + [] + ); + + const toggleExamplesMenu = useCallback(() => { + setShowExamplesMenu((prev) => !prev); + setShowSettingsMenu(false); + }, []); + + const toggleSettingsMenu = useCallback(() => { + setShowSettingsMenu((prev) => !prev); + setShowExamplesMenu(false); + }, []); + + const closeSourceLangDropdown = useCallback(() => setShowSourceLangDropdown(false), []); + + /** Closes the header menus — used when one of their items is chosen. */ + const closeHeaderMenus = useCallback(() => { + setShowSettingsMenu(false); + setShowExamplesMenu(false); + }, []); + + return { + showSourceLangDropdown, + showExamplesMenu, + showSettingsMenu, + toggleSourceLangDropdown, + toggleExamplesMenu, + toggleSettingsMenu, + closeSourceLangDropdown, + closeHeaderMenus, + }; +} diff --git a/src/hooks/useEditorSession.ts b/src/hooks/useEditorSession.ts new file mode 100644 index 0000000..cedbaa3 --- /dev/null +++ b/src/hooks/useEditorSession.ts @@ -0,0 +1,75 @@ +/** + * Saves and restores the editor session (source text, language, and which + * panels were open) so a reload picks up where the user left off. + */ + +import { useEffect } from 'react'; + +import type { SupportedLang } from '../components/LanguageSelector'; +import type { Panel } from '../components/editor/types'; +import { defaultPanelWidth } from '../components/editor/layoutConstants'; +import { randomId } from '../utils/id'; + +const EDITOR_STATE_KEY = 'praxly-editor-state'; + +export type PersistedEditorState = { + code: string; + sourceLang: SupportedLang; + openLangs: SupportedLang[]; + showAiChat: boolean; + showMemDia: boolean; +}; + +/** An embed link (`?code=...`) is an explicit share — it should win over, + * and not be clobbered into, any saved session in localStorage. */ +export const hasEmbedParam = () => Boolean(new URLSearchParams(window.location.search).get('code')); + +export const loadEditorState = (): PersistedEditorState | null => { + if (hasEmbedParam()) return null; + try { + const raw = localStorage.getItem(EDITOR_STATE_KEY); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +}; + +/** Rebuilds the translation panels a saved session had open. */ +export const restorePanels = (): Panel[] => { + const saved = loadEditorState(); + if (!saved || saved.openLangs.length === 0) return []; + + const width = defaultPanelWidth(); + + return saved.openLangs + .filter((lang) => lang !== saved.sourceLang) + .map((lang) => ({ id: randomId(), lang, width, sourceMap: new Map() })); +}; + +/** + * Writes the session back to localStorage, debounced so typing doesn't thrash + * it. Skipped for embed links — those are a one-off share, not a session. + */ +export function usePersistEditorState(state: PersistedEditorState, enabled: boolean): void { + const { code, sourceLang, showAiChat, showMemDia } = state; + // The panel list is rebuilt on every keystroke (source maps refresh with the + // AST), so key the effect on the languages themselves, not array identity. + const openLangsKey = state.openLangs.join(','); + + useEffect(() => { + if (!enabled) return; + + const id = window.setTimeout(() => { + const next: PersistedEditorState = { + code, + sourceLang, + openLangs: openLangsKey ? (openLangsKey.split(',') as SupportedLang[]) : [], + showAiChat, + showMemDia, + }; + localStorage.setItem(EDITOR_STATE_KEY, JSON.stringify(next)); + }, 250); + + return () => clearTimeout(id); + }, [code, sourceLang, openLangsKey, showAiChat, showMemDia, enabled]); +} diff --git a/src/hooks/useEditorShortcuts.ts b/src/hooks/useEditorShortcuts.ts new file mode 100644 index 0000000..a1f9ace --- /dev/null +++ b/src/hooks/useEditorShortcuts.ts @@ -0,0 +1,44 @@ +/** + * F5 / Shift+F5 / F10 keyboard shortcuts for running and debugging. + * + * The handlers close over fresh state on every render, so they are read through + * a ref — the document listener itself is registered only once. + */ + +import { useEffect, useRef } from 'react'; + +interface Shortcuts { + isDebugging: boolean; + isDebugComplete: boolean; + onRun: () => void; + onDebugStart: () => void; + onDebugStep: () => void; + onDebugStop: () => void; +} + +export function useEditorShortcuts(actions: Shortcuts) { + const actionsRef = useRef(actions); + actionsRef.current = actions; + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const current = actionsRef.current; + + if (e.key === 'F5') { + e.preventDefault(); + if (e.shiftKey) { + if (current.isDebugging) current.onDebugStop(); + else current.onDebugStart(); + } else if (!current.isDebugging) { + current.onRun(); + } + } else if (e.key === 'F10' && current.isDebugging && !current.isDebugComplete) { + e.preventDefault(); + current.onDebugStep(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, []); +} diff --git a/src/hooks/useEmbedData.ts b/src/hooks/useEmbedData.ts new file mode 100644 index 0000000..f03ffd0 --- /dev/null +++ b/src/hooks/useEmbedData.ts @@ -0,0 +1,44 @@ +/** + * Reads the program an embed link carries. Two link formats are supported: + * + * v2 ?code= — current `encodeEmbed` output + * v1 #code= — legacy links still in the wild + */ + +import { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router'; + +import { decodeEmbed, type EmbedData } from '../utils/embedCodec'; + +export function useEmbedData() { + const [searchParams] = useSearchParams(); + const [embedData, setEmbedData] = useState(null); + const [decodeError, setDecodeError] = useState(null); + + useEffect(() => { + const code = searchParams.get('code'); + if (code) { + const decoded = decodeEmbed(code); + if (!decoded) { + setDecodeError('Failed to decode embed data'); + return; + } + setEmbedData(decoded); + return; + } + + const hash = window.location.hash; + if (hash.startsWith('#code=')) { + const v1Code = decodeURIComponent(hash.slice('#code='.length)); + setEmbedData({ code: v1Code, lang: 'praxis' }); + return; + } + + setDecodeError('No code provided in URL'); + }, [searchParams]); + + /** Lets the user edit the embedded source in place. */ + const setCode = (code: string) => setEmbedData((prev) => (prev ? { ...prev, code } : null)); + + return { embedData, decodeError, setCode }; +} diff --git a/src/hooks/useEmbedExecution.ts b/src/hooks/useEmbedExecution.ts new file mode 100644 index 0000000..ebec482 --- /dev/null +++ b/src/hooks/useEmbedExecution.ts @@ -0,0 +1,181 @@ +/** + * Everything the embed player *does*: parsing the (editable) embedded source, + * running it, stepping the debugger, and collecting console output. Keeping it + * here leaves `EmbedPage` as a layout file. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { EditorView } from '@codemirror/view'; + +import type { Program } from '../language/ast'; +import type { SupportedLang } from '../components/LanguageSelector'; +import type { EmbedData } from '../utils/embedCodec'; +import { getCodeMirrorExtensions } from '../utils/editorUtils'; +import { highlightedLinesField, dispatchLineHighlighting } from '../utils/codemirrorConfig'; +import { useCodeParsing } from './useCodeParsing'; +import { useCodeDebugger } from './useCodeDebugger'; +import { useProgramRunner } from './useProgramRunner'; + +export function useEmbedExecution(embedData: EmbedData | null, targetLang: SupportedLang) { + const [output, setOutput] = useState([]); + const [ast, setAst] = useState(null); + // One error slot for both parsing and execution: a program that now parses + // clears whatever the last failed run reported, and vice versa. + const [error, setError] = useState(null); + + const { parseCode, getTranslation } = useCodeParsing(); + const { + isDebugging, + setIsDebugging, + isDebugComplete, + setIsDebugComplete, + highlightedSourceLines, + setHighlightedSourceLines, + highlightedTranslationLines, + setHighlightedTranslationLines, + currentCallStack, + waitingForInput, + inputPrompt, + initDebugger, + stepDebugger, + stopDebugger, + provideInput, + } = useCodeDebugger(getTranslation); + + const reportError = useCallback((message: string) => { + setError(message); + setOutput((prev) => [...prev, `Error: ${message}`]); + }, []); + + const runner = useProgramRunner({ onOutput: setOutput, onError: reportError }); + + const editorViewRef = useRef(null); + + const lang = (embedData?.lang ?? 'python') as SupportedLang; + const sourceCode = embedData?.code ?? ''; + + // Re-parse whenever the (editable) embedded source changes. + useEffect(() => { + if (!embedData) return; + + try { + setAst(parseCode(embedData.lang as SupportedLang, embedData.code)); + setError(null); + } catch (e: any) { + setError(e.message); + setAst(null); + } + }, [embedData, parseCode]); + + useEffect(() => { + dispatchLineHighlighting(editorViewRef, highlightedSourceLines); + }, [highlightedSourceLines]); + + const onCreateEditor = useCallback((view: EditorView) => { + editorViewRef.current = view; + }, []); + + const sourceExtensions = useMemo(() => { + const extensions = getCodeMirrorExtensions(lang); + extensions.push(highlightedLinesField); + return extensions; + }, [lang]); + + /** Applies one debugger step to the highlighting and console. */ + const applyDebugStep = useCallback(() => { + const result = stepDebugger(ast, sourceCode, targetLang); + if (!result) return; + + setHighlightedSourceLines(result.sourceHighlightedLines); + setHighlightedTranslationLines(result.translationHighlightedLines); + // outputLines is the cumulative program output, so replace rather than append. + setOutput(result.outputLines); + + if (result.isComplete) { + setIsDebugComplete(true); + setOutput((prev) => [...prev, 'Execution complete.']); + } + }, [ + ast, + sourceCode, + targetLang, + stepDebugger, + setHighlightedSourceLines, + setHighlightedTranslationLines, + setIsDebugComplete, + ]); + + const run = () => { + setError(null); + setOutput([]); + runner.reset(); + if (!ast) return; + runner.run(ast, lang, sourceCode); + }; + + const debugStart = () => { + setError(null); + setOutput([]); + + try { + if (!ast) return; + initDebugger(ast, lang, sourceCode); + setOutput(['Debugger initialized. Click Step to begin.']); + } catch (e: any) { + console.error(e); + setError(e.message); + } + }; + + const debugStep = () => { + if (!ast || !sourceCode) return; + + try { + applyDebugStep(); + } catch (e: any) { + console.error(e); + reportError(e.message); + setIsDebugComplete(true); + } + }; + + const debugStop = () => { + stopDebugger(); + setIsDebugging(false); + setOutput((prev) => [...prev, 'Debugger stopped.']); + }; + + /** Feeds a paused `input()` call — a plain run's, or the debugger's. */ + const submitInput = (input: string) => { + if (runner.waitingForInput) { + runner.submitInput(input); + return; + } + + provideInput(input); + if (!ast || !sourceCode) return; + // Let the debugger hook flush its state before stepping past the pause. + setTimeout(applyDebugStep, 0); + }; + + return { + ast, + output, + error, + lang, + sourceExtensions, + onCreateEditor, + getTranslation, + isDebugging, + isDebugComplete, + callStack: currentCallStack, + highlightedTranslationLines, + waitingForInput: waitingForInput || runner.waitingForInput, + inputPrompt: inputPrompt || runner.inputPrompt, + run, + debugStart, + debugStep, + debugStop, + submitInput, + }; +} diff --git a/src/hooks/useEmbedLinkImport.ts b/src/hooks/useEmbedLinkImport.ts new file mode 100644 index 0000000..dc5aff2 --- /dev/null +++ b/src/hooks/useEmbedLinkImport.ts @@ -0,0 +1,67 @@ +/** + * Loads a shared program into the editor when it is opened via an embed link + * (`/v2/editor?code=…&targetLang=…`), opening the requested translation panel + * alongside it. + */ + +import { useEffect } from 'react'; +import type { Dispatch, SetStateAction } from 'react'; +import { useSearchParams } from 'react-router'; + +import type { SupportedLang } from '../components/LanguageSelector'; +import type { Panel } from '../components/editor/types'; +import { defaultPanelWidth } from '../components/editor/layoutConstants'; +import { decodeEmbed } from '../utils/embedCodec'; +import { randomId } from '../utils/id'; + +const VALID_TARGET_LANGS = ['python', 'java', 'csp', 'praxis', 'javascript', 'blocks', 'ast']; + +interface UseEmbedLinkImportArgs { + setCode: (code: string) => void; + setSourceLang: (lang: SupportedLang) => void; + setPanels: Dispatch>; +} + +export function useEmbedLinkImport({ setCode, setSourceLang, setPanels }: UseEmbedLinkImportArgs) { + const [searchParams] = useSearchParams(); + + useEffect(() => { + const codeParam = searchParams.get('code'); + const targetLangParam = searchParams.get('targetLang'); + + if (!codeParam) return; + + try { + const decoded = decodeEmbed(codeParam); + if (!decoded) return; + + setCode(decoded.code); + setSourceLang(decoded.lang as SupportedLang); + + setPanels((prev) => { + // The source pane already shows this language — never duplicate it. + const next = prev.filter((panel) => panel.lang !== decoded.lang); + + const wantsPanel = + targetLangParam !== null && + VALID_TARGET_LANGS.includes(targetLangParam) && + targetLangParam !== decoded.lang && + !next.some((panel) => panel.lang === targetLangParam); + + if (!wantsPanel) return next; + + return [ + ...next, + { + id: randomId(), + lang: targetLangParam as SupportedLang, + width: defaultPanelWidth(), + sourceMap: new Map(), + }, + ]; + }); + } catch (e) { + console.error('Failed to decode embed:', e); + } + }, [searchParams, setCode, setSourceLang, setPanels]); +} diff --git a/src/hooks/useHighlightedEditor.ts b/src/hooks/useHighlightedEditor.ts new file mode 100644 index 0000000..4ada64c --- /dev/null +++ b/src/hooks/useHighlightedEditor.ts @@ -0,0 +1,24 @@ +/** + * Wires a CodeMirror instance to debugger line highlighting: keeps the view + * reference and re-dispatches the decoration effect whenever the highlighted + * lines change. + */ + +import { useCallback, useEffect, useRef } from 'react'; +import type { EditorView } from '@codemirror/view'; + +import { dispatchLineHighlighting } from '../utils/codemirrorConfig'; + +export function useHighlightedEditor(highlightedLines: number[]) { + const viewRef = useRef(null); + + useEffect(() => { + dispatchLineHighlighting(viewRef, highlightedLines); + }, [highlightedLines]); + + const onCreateEditor = useCallback((view: EditorView) => { + viewRef.current = view; + }, []); + + return onCreateEditor; +} diff --git a/src/hooks/useMemDiaPanes.ts b/src/hooks/useMemDiaPanes.ts new file mode 100644 index 0000000..27fd49f --- /dev/null +++ b/src/hooks/useMemDiaPanes.ts @@ -0,0 +1,101 @@ +/** + * Per-pane memory-diagram strips: how tall each one is, whether it is open, and + * the drag that resizes it. Keyed by pane id ('source' for the source pane, + * otherwise the translation panel's id) so every pane keeps its own setting. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { MouseEvent } from 'react'; + +import { + MAX_MEM_DIA_HEIGHT, + MIN_MEM_DIA_HEIGHT, + clamp, +} from '../components/editor/layoutConstants'; + +const DEFAULT_HEIGHT = 160; + +export function useMemDiaPanes() { + const [states, setStates] = useState>(new Map()); + const [heights, setHeights] = useState>({}); + const [resizingPaneId, setResizingPaneId] = useState(null); + + const resizeRef = useRef<{ paneId: string; startY: number; startHeight: number } | null>(null); + + const getHeight = useCallback((paneId: string) => heights[paneId] ?? DEFAULT_HEIGHT, [heights]); + + const getState = useCallback( + (paneId: string): 'open' | 'closed' => states.get(paneId) ?? 'open', + [states] + ); + + const toggle = useCallback((paneId: string) => { + setStates((prev) => { + const next = new Map(prev); + next.set(paneId, (next.get(paneId) ?? 'open') === 'open' ? 'closed' : 'open'); + return next; + }); + }, []); + + /** Reopens every pane — used when the diagrams are switched back on. */ + const resetStates = useCallback(() => setStates(new Map()), []); + + const startResize = useCallback( + (e: MouseEvent, paneId: string) => { + e.preventDefault(); + resizeRef.current = { paneId, startY: e.clientY, startHeight: getHeight(paneId) }; + setResizingPaneId(paneId); + }, + [getHeight] + ); + + useEffect(() => { + if (!resizingPaneId) return; + + const handleMouseMove = (e: globalThis.MouseEvent) => { + const drag = resizeRef.current; + if (!drag) return; + + const deltaY = e.clientY - drag.startY; + const rawHeight = drag.startHeight - deltaY; + + // Dragged well past its minimum, the strip collapses instead of sticking + // at the smallest open size. + if (rawHeight < MIN_MEM_DIA_HEIGHT - 40) { + setStates((prev) => { + const next = new Map(prev); + next.set(drag.paneId, 'closed'); + return next; + }); + return; + } + + const nextHeight = clamp(rawHeight, MIN_MEM_DIA_HEIGHT, MAX_MEM_DIA_HEIGHT); + setHeights((prev) => + prev[drag.paneId] === nextHeight ? prev : { ...prev, [drag.paneId]: nextHeight } + ); + setStates((prev) => { + if ((prev.get(drag.paneId) ?? 'open') === 'open') return prev; + const next = new Map(prev); + next.set(drag.paneId, 'open'); + return next; + }); + }; + + const handleMouseUp = () => { + resizeRef.current = null; + setResizingPaneId(null); + }; + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + }, [resizingPaneId]); + + return { getHeight, getState, toggle, resetStates, resizingPaneId, startResize }; +} + +export type MemDiaPanes = ReturnType; diff --git a/src/hooks/useOpenCodeBridge.ts b/src/hooks/useOpenCodeBridge.ts new file mode 100644 index 0000000..2daa6b5 --- /dev/null +++ b/src/hooks/useOpenCodeBridge.ts @@ -0,0 +1,37 @@ +/** + * "Open in editor" on an AI chat code block. The chat panel publishes the + * handler through `useEditorBridge`; only a mounted editor can service it, so + * the registration is torn down with the page. + */ + +import { useEffect, useRef } from 'react'; + +import type { SupportedLang } from '../components/LanguageSelector'; +import { useEditorBridge } from '../store/appStore'; + +/** Fence tags the AI writes, mapped to the languages Praxly supports. */ +const FENCE_LANGS: Record = { + praxis: 'praxis', + python: 'python', + py: 'python', + java: 'java', + csp: 'csp', + javascript: 'javascript', + js: 'javascript', +}; + +export function useOpenCodeBridge( + onOpenCode: (code: string, lang: SupportedLang | undefined) => void +) { + // The handler closes over fresh page state each render; the bridge + // registration below should happen only once. + const handlerRef = useRef(onOpenCode); + handlerRef.current = onOpenCode; + + useEffect(() => { + useEditorBridge.getState().setOpenCode((code, fenceLang) => { + handlerRef.current(code, FENCE_LANGS[fenceLang.trim().toLowerCase()]); + }); + return () => useEditorBridge.getState().setOpenCode(null); + }, []); +} diff --git a/src/hooks/usePanelSourceMaps.ts b/src/hooks/usePanelSourceMaps.ts new file mode 100644 index 0000000..88a3e5b --- /dev/null +++ b/src/hooks/usePanelSourceMaps.ts @@ -0,0 +1,33 @@ +/** + * Refreshes every open panel's source map when the program changes, so debug + * highlighting keeps pointing at the right line of each translation. + */ + +import { useEffect } from 'react'; +import type { Dispatch, SetStateAction } from 'react'; + +import type { Program } from '../language/ast'; +import type { SupportedLang } from '../components/LanguageSelector'; +import type { Panel } from '../components/editor/types'; +import type { SourceMap } from './useCodeParsing'; + +export function usePanelSourceMaps( + ast: Program | null, + setPanels: Dispatch>, + getTranslation: ( + ast: Program | null, + lang: SupportedLang + ) => { code: string; sourceMap: SourceMap } +) { + useEffect(() => { + if (!ast) return; + + setPanels((prev) => { + if (prev.length === 0) return prev; + return prev.map((panel) => ({ + ...panel, + sourceMap: getTranslation(ast, panel.lang).sourceMap, + })); + }); + }, [ast, getTranslation, setPanels]); +} diff --git a/src/hooks/useProgramRunner.ts b/src/hooks/useProgramRunner.ts new file mode 100644 index 0000000..4907257 --- /dev/null +++ b/src/hooks/useProgramRunner.ts @@ -0,0 +1,114 @@ +/** + * useProgramRunner drives a plain (non-debug) execution of a parsed program. + * + * It runs on the `Debugger` rather than the interpreter directly so a program + * that calls `input()` can pause mid-run and resume with the user's answer, + * instead of being re-executed from the top on every keystroke. Both the full + * editor and the embed player share it, so a run behaves the same in each. + */ + +import { useCallback, useRef, useState } from 'react'; + +import type { Program } from '../language/ast'; +import type { SupportedLang } from '../components/LanguageSelector'; +import { Debugger } from '../language/debugger'; + +interface RunnerCallbacks { + /** Called with the cumulative program output after every step. */ + onOutput: (lines: string[]) => void; + /** Called when execution throws; the message is already logged. */ + onError: (message: string) => void; +} + +export interface ProgramRunner { + /** True while a run is parked on an `input()` call. */ + waitingForInput: boolean; + /** Prompt text for the pending `input()` call, if any. */ + inputPrompt: string; + /** Runs `program` to completion, or until it pauses for input. */ + run: (program: Program, lang: SupportedLang, sourceCode: string) => void; + /** Answers the pending `input()` call and resumes to completion. */ + submitInput: (input: string) => void; + /** Abandons any paused run — used when the editor's program is replaced. */ + reset: () => void; +} + +export function useProgramRunner(callbacks: RunnerCallbacks): ProgramRunner { + const [waitingForInput, setWaitingForInput] = useState(false); + const [inputPrompt, setInputPrompt] = useState(''); + + // The Debugger instance behind a run that paused for input(). Held in a ref + // rather than state: nothing renders from it, and the input handler needs + // the value the *current* run wrote, not the one from its closure. + const pausedRunRef = useRef(null); + + // Callbacks are recreated on every render of the calling page; reading them + // through a ref keeps `run`/`submitInput` stable identities. + const callbacksRef = useRef(callbacks); + callbacksRef.current = callbacks; + + const reset = useCallback(() => { + pausedRunRef.current = null; + setWaitingForInput(false); + setInputPrompt(''); + }, []); + + /** Steps `instance` until it finishes or parks on input(). */ + const drain = useCallback( + (start: () => Debugger) => { + try { + const instance = start(); + const { onOutput } = callbacksRef.current; + let step = instance.step(); + + while (step && !step.isComplete) { + // `step.output` is the cumulative output so far, so replace rather + // than append. + onOutput(step.output); + + if (step.waitingForInput) { + pausedRunRef.current = instance; + setWaitingForInput(true); + setInputPrompt(step.inputPrompt || ''); + return; + } + + step = instance.step(); + } + + if (step) onOutput(step.output); + reset(); + } catch (e: any) { + console.error(e); + reset(); + callbacksRef.current.onError(e.message); + } + }, + [reset] + ); + + const run = useCallback( + (program: Program, lang: SupportedLang, sourceCode: string) => { + drain(() => { + const instance = new Debugger(); + instance.init(program, lang, sourceCode); + return instance; + }); + }, + [drain] + ); + + const submitInput = useCallback( + (input: string) => { + const paused = pausedRunRef.current; + if (!paused) return; + drain(() => { + paused.provideInput(input); + return paused; + }); + }, + [drain] + ); + + return { waitingForInput, inputPrompt, run, submitInput, reset }; +} diff --git a/src/hooks/useTextSize.ts b/src/hooks/useTextSize.ts new file mode 100644 index 0000000..79d9447 --- /dev/null +++ b/src/hooks/useTextSize.ts @@ -0,0 +1,27 @@ +/** + * Settings → text size. Persisted on its own key (not part of the session + * snapshot) because it is a display preference, not part of the program. + */ + +import { useState } from 'react'; + +import type { TextSize } from '../components/editor/EditorHeader'; + +const STORAGE_KEY = 'praxly-text-size'; + +export function useTextSize() { + const [textSize, setStoredSize] = useState( + () => (Number(localStorage.getItem(STORAGE_KEY)) || 3) as TextSize + ); + + const setTextSize = (size: TextSize) => { + setStoredSize(size); + localStorage.setItem(STORAGE_KEY, String(size)); + }; + + // CodeMirror reads this through a CSS variable; Blockly panes take the + // numeric value to re-theme their SVG text. + const fontSizePx = 10 + textSize * 2; + + return { textSize, setTextSize, fontSizePx }; +} diff --git a/src/hooks/useTranslationPanels.ts b/src/hooks/useTranslationPanels.ts new file mode 100644 index 0000000..6075363 --- /dev/null +++ b/src/hooks/useTranslationPanels.ts @@ -0,0 +1,133 @@ +/** + * Owns the list of open translation panels: which languages are showing, how + * they are arranged into columns, and the drag-and-drop that rearranges them. + * + * Panel *widths* are set by `useEditorLayout`, which re-splits the row whenever + * the column count changes — so a newly opened panel's starting width is only + * ever visible for a frame. + */ + +import { useCallback, useMemo, useState } from 'react'; +import type { DragEvent } from 'react'; + +import type { Program } from '../language/ast'; +import type { SupportedLang } from '../components/LanguageSelector'; +import type { Panel } from '../components/editor/types'; +import { defaultPanelWidth } from '../components/editor/layoutConstants'; +import { restorePanels } from './useEditorSession'; +import { randomId } from '../utils/id'; +import { + inSameColumn, + removePanelById, + reorderPanel, + swapStacked, + toggleStack, +} from '../utils/panelLayout'; +import type { SourceMap } from './useCodeParsing'; + +type GetTranslation = ( + ast: Program | null, + lang: SupportedLang +) => { code: string; sourceMap: SourceMap }; + +export function useTranslationPanels(getTranslation: GetTranslation) { + const [panels, setPanels] = useState(restorePanels); + const [draggedPanelId, setDraggedPanelId] = useState(null); + const [dragOverPanelId, setDragOverPanelId] = useState(null); + + const rootPanels = useMemo(() => panels.filter((panel) => !panel.stackedUnder), [panels]); + + // The rightmost column stretches to fill whatever the panes to its left + // leave over, so dragging any divider can never expose a bare strip of the + // row's background. Its stored `width` goes unused while it holds this spot. + const elasticPanelId = rootPanels.length > 0 ? rootPanels[rootPanels.length - 1].id : null; + + /** `ast` seeds the panel's source map so debug highlighting works right away. */ + const addPanel = useCallback( + (lang: SupportedLang, ast: Program | null) => { + setPanels((prev) => { + if (prev.some((panel) => panel.lang === lang)) return prev; + + const { sourceMap } = getTranslation(ast, lang); + return [...prev, { id: randomId(), lang, width: defaultPanelWidth(), sourceMap }]; + }); + }, + [getTranslation] + ); + + const removePanel = useCallback((id: string) => { + setPanels((prev) => removePanelById(prev, id)); + }, []); + + /** Opens or closes a translation panel without closing the add menu. */ + const togglePanel = useCallback( + (lang: SupportedLang, ast: Program | null) => { + const existing = panels.find((panel) => panel.lang === lang); + if (existing) removePanel(existing.id); + else addPanel(lang, ast); + }, + [panels, addPanel, removePanel] + ); + + const togglePanelStack = useCallback((id: string) => { + setPanels((prev) => toggleStack(prev, id)); + }, []); + + /** Drops any panel showing `lang` — the source pane already covers it. */ + const dropPanelsForLang = useCallback((lang: SupportedLang) => { + setPanels((prev) => prev.filter((panel) => panel.lang !== lang)); + }, []); + + const dragHandlers = useMemo( + () => ({ + onPanelDragStart: (e: DragEvent, panelId: string) => { + setDraggedPanelId(panelId); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', panelId); + }, + onPanelDragOver: (e: DragEvent, panelId: string) => { + if (!draggedPanelId || draggedPanelId === panelId) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setDragOverPanelId((prev) => (prev === panelId ? prev : panelId)); + }, + onPanelDrop: (e: DragEvent, panelId: string) => { + e.preventDefault(); + const sourceId = draggedPanelId || e.dataTransfer.getData('text/plain'); + + if (sourceId && sourceId !== panelId) { + setPanels((prev) => + inSameColumn(prev, sourceId, panelId) + ? swapStacked(prev, sourceId, panelId) + : reorderPanel(prev, sourceId, panelId) + ); + } + + setDragOverPanelId(null); + setDraggedPanelId(null); + }, + onPanelDragEnd: () => { + setDraggedPanelId(null); + setDragOverPanelId(null); + }, + }), + [draggedPanelId] + ); + + return { + panels, + setPanels, + rootPanels, + elasticPanelId, + draggedPanelId, + dragOverPanelId, + addPanel, + removePanel, + togglePanel, + togglePanelStack, + dropPanelsForLang, + dragHandlers, + }; +} + +export type TranslationPanels = ReturnType; diff --git a/src/pages/AccountPage.tsx b/src/pages/AccountPage.tsx index fc190ca..066402f 100644 --- a/src/pages/AccountPage.tsx +++ b/src/pages/AccountPage.tsx @@ -1,234 +1,49 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { Link } from 'react-router-dom'; -import { - ArrowLeft, - Check, - Eye, - EyeOff, - GraduationCap, - KeyRound, - LogOut, - MessageSquare, - Pencil, - ShieldCheck, - Trash2, - User, - X, -} from 'lucide-react'; -import { isAuthenticated, logout } from '../api/auth'; -import { SignInButtons } from '../components/auth/SignInButtons'; -import { - getAccount, - getUsage, - updateProfile, - changePassword, - type AccountProfile, - type AccountUsage, -} from '../api/account'; -import { listChats, deleteChatApi, renameChatApi, type SessionMeta } from '../api/chat'; -import { - useAiPrefsStore, - useChatStore, - type AiLevel, - type AiRole, - type ByokProvider, -} from '../store/appStore'; -import { PROVIDER_OPTIONS, useByokDraft } from '../components/ai/byok'; +import { useState } from 'react'; + +import { isAuthenticated } from '../api/auth'; +import { AccountHeader } from '../components/account/AccountHeader'; +import { AccountNav, AccountNavMobile } from '../components/account/AccountNav'; +import { AiSettingsSection } from '../components/account/AiSettingsSection'; +import { DataSection } from '../components/account/DataSection'; +import { HomeSection } from '../components/account/HomeSection'; +import { PersonalSection } from '../components/account/PersonalSection'; +import { ProfileSection } from '../components/account/ProfileSection'; +import { SecuritySection } from '../components/account/SecuritySection'; +import { SignedOutCard } from '../components/account/SignedOutCard'; +import { StatusBanner } from '../components/account/StatusBanner'; +import { cardCls } from '../components/account/styles'; +import type { Section } from '../components/account/types'; +import { useAccountData } from '../hooks/useAccountData'; /** * Google-Account-style manager for the user's Praxly account (Keycloak or - * Google — whichever they signed in with): - * personal info (email/name), security (password), and data & activity - * (usage stats + chat history management). + * Google — whichever they signed in with): personal info (email/name), + * security (password), AI settings, and data & activity (usage stats + chat + * history management). + * + * This page is only the shell — nav, status banner, and section switch. Every + * pane lives in `components/account/` and owns its own form state. */ - -type Section = 'home' | 'personal' | 'security' | 'ai' | 'profile' | 'data'; - -const NAV: Array<{ id: Section; label: string; icon: typeof User }> = [ - { id: 'home', label: 'Home', icon: User }, - { id: 'personal', label: 'Personal info', icon: Pencil }, - { id: 'security', label: 'Security', icon: ShieldCheck }, - { id: 'ai', label: 'AI model & API key', icon: KeyRound }, - { id: 'profile', label: 'Tutor profile', icon: GraduationCap }, - { id: 'data', label: 'Data & activity', icon: MessageSquare }, -]; - -const cardCls = 'rounded-2xl border border-slate-800 bg-slate-900'; -const inputCls = - 'w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30 transition'; -const primaryBtnCls = - 'rounded-full bg-indigo-600 px-5 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-40 disabled:cursor-not-allowed transition-colors'; -const textBtnCls = - 'rounded-full px-4 py-2 text-sm font-medium text-indigo-300 hover:bg-slate-800 transition-colors'; - -function displayName(profile: AccountProfile | null): string { - if (!profile) return ''; - const full = [profile.firstName, profile.lastName].filter(Boolean).join(' '); - return full || profile.username; -} - -function initialOf(profile: AccountProfile | null): string { - const name = displayName(profile) || profile?.email || '?'; - return name.charAt(0).toUpperCase(); -} - -function Avatar({ profile, size }: { profile: AccountProfile | null; size: 'sm' | 'lg' }) { - const cls = size === 'lg' ? 'h-20 w-20 text-3xl' : 'h-9 w-9 text-sm'; - return ( - - ); -} - -function StatusBanner({ - status, - onDismiss, -}: { - status: { kind: 'success' | 'error'; text: string } | null; - onDismiss: () => void; -}) { - if (!status) return null; - return ( -
- - {status.kind === 'success' ? : } - {status.text} - - -
- ); -} - export default function AccountPage() { const [section, setSection] = useState
('home'); - const [profile, setProfile] = useState(null); - const [usage, setUsage] = useState(null); - const [chats, setChats] = useState([]); - const [loading, setLoading] = useState(true); - const [status, setStatus] = useState<{ kind: 'success' | 'error'; text: string } | null>(null); - - const notify = useCallback((kind: 'success' | 'error', text: string) => { - setStatus({ kind, text }); - }, []); - - useEffect(() => { - if (!isAuthenticated()) { - setLoading(false); - return; - } - Promise.allSettled([getAccount(), getUsage(), listChats()]).then( - ([profileRes, usageRes, chatsRes]) => { - if (profileRes.status === 'fulfilled') setProfile(profileRes.value); - if (usageRes.status === 'fulfilled') setUsage(usageRes.value); - if (chatsRes.status === 'fulfilled') setChats(chatsRes.value); - setLoading(false); - } - ); - }, []); + const { profile, setProfile, usage, chats, setChats, loading, status, notify, dismissStatus } = + useAccountData(); if (!isAuthenticated()) { - return ( -
-
-

Praxly Account

-

Sign in to manage your account.

-
- -
- - Back to editor - -
-
- ); + return ; } return (
- {/* Top bar */} -
-
- - - - - - Praxly Account - -
-
- - -
-
+
- {/* Left nav */} - + - {/* Content */}
- {/* Mobile section switcher */} -
- {NAV.map(({ id, label }) => ( - - ))} -
+ - setStatus(null)} /> + {loading ? (
Loading…
@@ -250,635 +65,3 @@ export default function AccountPage() {
); } - -// ── Home ───────────────────────────────────────────────────────────────────── - -function HomeSection({ - profile, - usage, - onNavigate, -}: { - profile: AccountProfile | null; - usage: AccountUsage | null; - onNavigate: (s: Section) => void; -}) { - return ( -
-
- -

- Welcome, {displayName(profile) || 'student'} -

-

- Manage your info, security, and chat activity to make Praxly work better for you. -

-
- -
- - - -
-
- ); -} - -// ── Personal info ──────────────────────────────────────────────────────────── - -function PersonalSection({ - profile, - setProfile, - notify, -}: { - profile: AccountProfile | null; - setProfile: (p: AccountProfile) => void; - notify: (kind: 'success' | 'error', text: string) => void; -}) { - const [email, setEmail] = useState(profile?.email ?? ''); - const [firstName, setFirstName] = useState(profile?.firstName ?? ''); - const [lastName, setLastName] = useState(profile?.lastName ?? ''); - const [saving, setSaving] = useState(false); - - // Google asserts the email on every sign-in; only Keycloak accounts own theirs. - const emailEditable = profile?.provider !== 'google'; - - const dirty = - (emailEditable && email !== (profile?.email ?? '')) || - firstName !== (profile?.firstName ?? '') || - lastName !== (profile?.lastName ?? ''); - - const handleSave = async () => { - if (!profile) return; - setSaving(true); - try { - await updateProfile({ ...(emailEditable ? { email } : {}), firstName, lastName }); - setProfile({ ...profile, ...(emailEditable ? { email } : {}), firstName, lastName }); - notify('success', 'Profile updated. Changes to your email apply the next time you sign in.'); - } catch (e) { - notify('error', e instanceof Error ? e.message : 'Failed to update profile'); - } finally { - setSaving(false); - } - }; - - return ( -
-
-

Personal info

-

- The email and name attached to your school account. -

-
-
-
- - -
-
- - setEmail(e.target.value)} - disabled={!emailEditable} - className={emailEditable ? inputCls : `${inputCls} text-slate-500 opacity-70`} - placeholder="you@example.com" - /> - {!emailEditable && ( -

- Your email comes from your Google Account and can't be changed here. -

- )} -
-
-
- - setFirstName(e.target.value)} - className={inputCls} - /> -
-
- - setLastName(e.target.value)} - className={inputCls} - /> -
-
-
- -
-
-
- ); -} - -// ── Security ───────────────────────────────────────────────────────────────── - -function SecuritySection({ - profile, - notify, -}: { - profile: AccountProfile | null; - notify: (kind: 'success' | 'error', text: string) => void; -}) { - const [current, setCurrent] = useState(''); - const [next, setNext] = useState(''); - const [confirm, setConfirm] = useState(''); - const [saving, setSaving] = useState(false); - - const canSubmit = current.length > 0 && next.length >= 8 && next === confirm && !saving; - - const handleSubmit = async () => { - setSaving(true); - try { - await changePassword(current, next); - setCurrent(''); - setNext(''); - setConfirm(''); - notify('success', 'Password changed.'); - } catch (e) { - notify('error', e instanceof Error ? e.message : 'Failed to change password'); - } finally { - setSaving(false); - } - }; - - // Google owns the credentials for these accounts — there is no password here - // to change, and pretending otherwise would just fail at the backend. - if (profile?.provider === 'google') { - return ( -
-
-

Security

-

- You sign in to Praxly with Google, so your password is managed by Google. -

-
- -
- ); - } - - return ( -
-
-

Security

-

- Change the password for your school account. It must be at least 8 characters. -

-
-
-
- - setCurrent(e.target.value)} - className={inputCls} - autoComplete="current-password" - /> -
-
- - setNext(e.target.value)} - className={inputCls} - autoComplete="new-password" - /> -
-
- - setConfirm(e.target.value)} - className={inputCls} - autoComplete="new-password" - /> - {confirm.length > 0 && next !== confirm && ( -

Passwords don't match.

- )} -
-
- -
-
-
- ); -} - -// ── AI model & API key ─────────────────────────────────────────────────────── - -function AiSettingsSection({ - notify, -}: { - notify: (kind: 'success' | 'error', text: string) => void; -}) { - const draft = useByokDraft(); - const [showKey, setShowKey] = useState(false); - - const handleSave = () => { - if (!draft.save()) return; - notify( - 'success', - draft.draftProvider === '' ? 'Using the school-provided model.' : 'API key saved.' - ); - }; - - return ( -
-
-

AI model & API key

-

- Pick which model powers the AI Assistant. Using your own API key gives the best experience - — it stays in this browser and is only used to make your requests. The school-provided - model works without a key. -

-
-
-
- - -
- - {draft.needsKey && ( - <> -
- -
- draft.setDraftKey(e.target.value)} - placeholder="Paste your API key" - autoComplete="off" - spellCheck={false} - className={`${inputCls} pr-10`} - /> - -
- {draft.hint && ( -

- Get a key at{' '} - - {draft.hint} - -

- )} -
- -
- - draft.setDraftModel(e.target.value)} - placeholder="Leave blank for the recommended model" - autoComplete="off" - spellCheck={false} - className={inputCls} - /> -
- - )} - -
- -
-
-
- ); -} - -// ── Tutor profile ──────────────────────────────────────────────────────────── - -const ROLE_OPTIONS: Array<{ value: AiRole; label: string; hint: string }> = [ - { value: 'student', label: 'Student', hint: 'Guides you toward answers with hints' }, - { value: 'teacher', label: 'Teacher', hint: 'Direct answers plus teaching tips' }, -]; - -const LEVEL_OPTIONS: Array<{ value: AiLevel; label: string; hint: string }> = [ - { value: 'novice', label: 'Novice', hint: 'New to programming — explain everything' }, - { value: 'intermediate', label: 'Intermediate', hint: 'Knows the basics' }, - { value: 'advanced', label: 'Advanced', hint: 'Comfortable — skip the fundamentals' }, -]; - -function ProfileSection() { - const { role, level, setRole, setLevel } = useAiPrefsStore(); - - const optionBtn = (selected: boolean) => - `w-full text-left rounded-lg border px-3 py-2 transition-colors ${ - selected - ? 'border-indigo-500 bg-indigo-500/10' - : 'border-slate-700 bg-slate-800 hover:border-slate-500' - }`; - - return ( -
-
-

Tutor profile

-

- The AI adjusts how it responds based on who you are and how much programming you already - know. -

-
-
-
- I am a… -
- {ROLE_OPTIONS.map((o) => ( - - ))} -
-
-
- Experience level -
- {LEVEL_OPTIONS.map((o) => ( - - ))} -
-
-
-
- ); -} - -// ── Data & activity ────────────────────────────────────────────────────────── - -function DataSection({ - usage, - chats, - setChats, - notify, -}: { - usage: AccountUsage | null; - chats: SessionMeta[]; - setChats: (c: SessionMeta[]) => void; - notify: (kind: 'success' | 'error', text: string) => void; -}) { - const removeSession = useChatStore((s) => s.removeSession); - const updateSession = useChatStore((s) => s.updateSession); - const [renamingId, setRenamingId] = useState(null); - const [renameValue, setRenameValue] = useState(''); - - // Fill the last 30 days so quiet days render as empty bars, not gaps. - const bars = useMemo(() => { - const byDay = new Map((usage?.daily ?? []).map((d) => [d.day, d.count])); - const days: Array<{ day: string; count: number }> = []; - for (let i = 29; i >= 0; i--) { - const d = new Date(); - d.setDate(d.getDate() - i); - const key = d.toISOString().slice(0, 10); - days.push({ day: key, count: byDay.get(key) ?? 0 }); - } - return days; - }, [usage]); - const maxCount = Math.max(1, ...bars.map((b) => b.count)); - - const handleDelete = async (id: string) => { - if (!window.confirm('Delete this chat? This cannot be undone.')) return; - try { - await deleteChatApi(id); - removeSession(id); - setChats(chats.filter((c) => c.id !== id)); - notify('success', 'Chat deleted.'); - } catch { - notify('error', 'Failed to delete chat.'); - } - }; - - const handleRename = async (id: string) => { - const title = renameValue.trim(); - setRenamingId(null); - if (!title) return; - try { - await renameChatApi(id, title); - updateSession(id, { title }); - setChats(chats.map((c) => (c.id === id ? { ...c, title } : c))); - } catch { - notify('error', 'Failed to rename chat.'); - } - }; - - return ( -
- {/* Usage */} -
-

AI usage

-
- {[ - { label: 'Chats', value: usage?.sessions ?? 0 }, - { label: 'Messages', value: usage?.messages ?? 0 }, - { label: 'Sent', value: usage?.sent ?? 0 }, - { label: 'Received', value: usage?.received ?? 0 }, - ].map((stat) => ( -
-
{stat.value}
-
{stat.label}
-
- ))} -
- -

- Last 30 days -

-
- {bars.map((b) => ( -
0 ? 'bg-indigo-500/100' : 'bg-slate-800'}`} - style={{ height: `${Math.max(4, (b.count / maxCount) * 100)}%` }} - /> - ))} -
-
- - {/* Chat history */} -
-
-

Chat history

-

- Rename or delete your saved conversations with the Praxly tutor. -

-
- {chats.length === 0 ? ( -

No chats yet.

- ) : ( -
    - {chats.map((chat) => ( -
  • - - {renamingId === chat.id ? ( - setRenameValue(e.target.value)} - onBlur={() => handleRename(chat.id)} - onKeyDown={(e) => { - if (e.key === 'Enter') handleRename(chat.id); - if (e.key === 'Escape') setRenamingId(null); - }} - className={`${inputCls} max-w-xs py-1`} - /> - ) : ( - - {chat.title ?? 'Untitled chat'} - - )} - - {new Date(chat.updatedAt).toLocaleDateString()} - - - -
  • - ))} -
- )} -
-
- ); -} diff --git a/src/pages/EditorPage.tsx b/src/pages/EditorPage.tsx index 3f7b3de..c63ba03 100644 --- a/src/pages/EditorPage.tsx +++ b/src/pages/EditorPage.tsx @@ -1,747 +1,157 @@ -import { useState, useCallback, useEffect, useMemo, useRef } from 'react'; -import type { DragEvent, MouseEvent } from 'react'; -import { useSearchParams } from 'react-router-dom'; -import { Sparkles } from 'lucide-react'; -import type { EditorView, ViewUpdate } from '@codemirror/view'; - -import type { Program } from '../language/ast'; -import { LANG_LABELS, type SupportedLang } from '../components/LanguageSelector'; -import { ConfirmModal } from '../components/ConfirmModal'; +/** + * The full Praxly editor: a source pane plus any number of live translation + * panes, a console, memory diagrams, and the AI assistant. + * + * This file is composition only. The behaviour lives in focused hooks — + * `useEditorExecution` (parse/run/debug), `useTranslationPanels` (which panes + * are open), `useEditorLayout` (how big everything is), `useMemDiaPanes`, + * `useEditorSession` (persistence) — and the panes themselves live in + * `components/editor/`. + */ + +import { useMemo, useRef, useState } from 'react'; + +import { type SupportedLang } from '../components/LanguageSelector'; import { OutputPanel } from '../components/OutputPanel'; -import { highlightedLinesField, dispatchLineHighlighting } from '../utils/codemirrorConfig'; -import { computeMultiplePanelHighlighting } from '../utils/debugHandlers'; -import { decodeEmbed, encodeEmbed, copyToClipboard } from '../utils/embedCodec'; +import { highlightedLinesField } from '../utils/codemirrorConfig'; +import { encodeEmbed, copyToClipboard } from '../utils/embedCodec'; import { getCodeMirrorExtensions } from '../utils/editorUtils'; import { EXAMPLE_PROGRAMS, getExampleById } from '../utils/sampleCodes'; import { getDemoForLang } from '../utils/demoPrograms'; +import { buildAiPanelContext } from '../utils/aiPanelContext'; +import { planLanguageSwitch } from '../utils/languageSwitch'; + import { useCodeParsing } from '../hooks/useCodeParsing'; -import { useCodeDebugger } from '../hooks/useCodeDebugger'; -import { useClickOutside } from '../hooks/useClickOutside'; -import { randomId } from '../utils/id'; -import { - inSameColumn, - removePanelById, - reorderPanel, - swapStacked, - toggleStack, -} from '../utils/panelLayout'; -import { Debugger } from '../language/debugger'; +import { useAiSelection } from '../hooks/useAiSelection'; +import { useEditorExecution } from '../hooks/useEditorExecution'; +import { useEditorLayout } from '../hooks/useEditorLayout'; +import { useEditorMenus } from '../hooks/useEditorMenus'; +import { useEditorShortcuts } from '../hooks/useEditorShortcuts'; +import { useEmbedLinkImport } from '../hooks/useEmbedLinkImport'; +import { useHighlightedEditor } from '../hooks/useHighlightedEditor'; +import { useMemDiaPanes } from '../hooks/useMemDiaPanes'; +import { useOpenCodeBridge } from '../hooks/useOpenCodeBridge'; +import { usePanelSourceMaps } from '../hooks/usePanelSourceMaps'; +import { useTextSize } from '../hooks/useTextSize'; +import { useTranslationPanels } from '../hooks/useTranslationPanels'; +import { hasEmbedParam, loadEditorState, usePersistEditorState } from '../hooks/useEditorSession'; -import { EditorHeader } from '../components/editor/EditorHeader'; -import type { TextSize } from '../components/editor/EditorHeader'; -import { SourcePane } from '../components/editor/SourcePane'; -import { TranslationPaneItem } from '../components/editor/TranslationPaneItem'; import { AddPanelStrip } from '../components/editor/AddPanelStrip'; import { AiSidePanel } from '../components/editor/AiSidePanel'; -import { useEditorBridge } from '../store/appStore'; -import type { LlmPanel } from '../api/llm'; -import type { Panel } from '../components/editor/types'; - -const MIN_SOURCE_WIDTH = 280; -const MIN_PANEL_WIDTH = 240; -const MIN_OUTPUT_HEIGHT = 120; -const MIN_MEM_DIA_HEIGHT = 100; -const MAX_MEM_DIA_HEIGHT = 360; -const MIN_AI_PANEL_WIDTH = 260; -const MAX_AI_PANEL_WIDTH = 560; -const ADD_STRIP_WIDTH = 64; -const MOBILE_BREAKPOINT = 1024; - -const clamp = (value: number, min: number, max: number): number => - Math.min(Math.max(value, min), max); - -const EDITOR_STATE_KEY = 'praxly-editor-state'; - -type PersistedEditorState = { - code: string; - sourceLang: SupportedLang; - openLangs: SupportedLang[]; - showAiChat: boolean; - showMemDia: boolean; -}; - -/** An embed link (`?code=...`) is an explicit share — it should win over, - * and not be clobbered into, any saved session in localStorage. */ -const hasEmbedParam = () => Boolean(new URLSearchParams(window.location.search).get('code')); - -const loadEditorState = (): PersistedEditorState | null => { - if (hasEmbedParam()) return null; - try { - const raw = localStorage.getItem(EDITOR_STATE_KEY); - return raw ? JSON.parse(raw) : null; - } catch { - return null; - } -}; +import { AskAiButton } from '../components/editor/AskAiButton'; +import { EditorDialogs } from '../components/editor/EditorDialogs'; +import { EditorHeader } from '../components/editor/EditorHeader'; +import { SourcePane } from '../components/editor/SourcePane'; +import { TranslationPanelGrid } from '../components/editor/TranslationPanelGrid'; export default function EditorPage() { - const [searchParams] = useSearchParams(); const [loadedViaEmbedLink] = useState(hasEmbedParam); const [code, setCode] = useState(() => loadEditorState()?.code ?? ''); - const [output, setOutput] = useState([]); - // Once true, the "Run code to see execution results..." placeholder never - // reappears — it's a first-run hint, not a state to flash on every re-run. - const [hasRun, setHasRun] = useState(false); - const [ast, setAst] = useState(null); const [sourceLang, setSourceLang] = useState( () => loadEditorState()?.sourceLang ?? 'praxis' ); - const [error, setError] = useState(null); - const [showSourceLangDropdown, setShowSourceLangDropdown] = useState(false); - const [showExamplesMenu, setShowExamplesMenu] = useState(false); - const [embedCopied, setEmbedCopied] = useState(false); - const [draggedPanelId, setDraggedPanelId] = useState(null); - const [dragOverPanelId, setDragOverPanelId] = useState(null); - const [showSettingsMenu, setShowSettingsMenu] = useState(false); const [showAiSidePanel, setShowAiSidePanel] = useState( () => loadEditorState()?.showAiChat ?? false ); const [showMemDia, setShowMemDia] = useState(() => loadEditorState()?.showMemDia ?? false); - const [outputState, setOutputState] = useState<'open' | 'closed'>('open'); - const [textSize, setTextSize] = useState( - () => Number(localStorage.getItem('praxly-text-size')) || 3 - ); - const [memDiaStates, setMemDiaStates] = useState>(new Map()); - const [aiPanelWidth, setAiPanelWidth] = useState(320); - const [isResizingAiPanel, setIsResizingAiPanel] = useState(false); - // Language the user asked to switch to when the program couldn't be - // translated — non-null while the "start fresh?" dialog is open. + + // Pending "this will discard your code" confirmations (see EditorDialogs). const [pendingLangSwitch, setPendingLangSwitch] = useState(null); - // Example id awaiting confirmation to replace a non-blank editor. const [pendingExampleId, setPendingExampleId] = useState(null); - // True while confirming replacing a non-blank editor with the demo program. const [pendingDemoLoad, setPendingDemoLoad] = useState(false); - // Highlight-to-chat: text selected in the source editor + where to float the button. - const [aiSelection, setAiSelection] = useState(''); - const [aiSelectionCoords, setAiSelectionCoords] = useState<{ top: number; left: number } | null>( - null - ); - const [resizingMemDiaPaneId, setResizingMemDiaPaneId] = useState(null); - const [memDiaHeights, setMemDiaHeights] = useState>({}); - const [viewportWidth, setViewportWidth] = useState(window.innerWidth); - - const [editorWidth, setEditorWidth] = useState( - Math.max(MIN_SOURCE_WIDTH, Math.floor(window.innerWidth * 0.45)) - ); - const [panels, setPanels] = useState(() => { - const saved = loadEditorState(); - if (!saved || saved.openLangs.length === 0) return []; - - const defaultPanelWidth = - window.innerWidth < MOBILE_BREAKPOINT - ? Math.max(MIN_PANEL_WIDTH, Math.floor(window.innerWidth * 0.8)) - : 350; - - return saved.openLangs - .filter((lang) => lang !== saved.sourceLang) - .map((lang) => ({ - id: randomId(), - lang, - width: defaultPanelWidth, - sourceMap: new Map(), - })); - }); + const [embedCopied, setEmbedCopied] = useState(false); - const [waitingForNormalInput, setWaitingForNormalInput] = useState(false); - const [normalModeInputPrompt, setNormalModeInputPrompt] = useState(''); - // The Debugger instance driving a non-debug run that paused for input(). - const [currentInterpreter, setCurrentInterpreter] = useState(null); + const editorRef = useRef(null); const { parseCode, getTranslation } = useCodeParsing(); - const { - isDebugging, - setIsDebugging, - isDebugComplete, - setIsDebugComplete, - highlightedSourceLines, - setHighlightedSourceLines, - currentVariables, - currentCallStack, - waitingForInput, - inputPrompt, - initDebugger, - stepDebugger, - stopDebugger, - provideInput, - } = useCodeDebugger(getTranslation); - - const [resizingIdx, setResizingIdx] = useState(null); - const [outputHeight, setOutputHeight] = useState( - Math.max(176, Math.floor(window.innerHeight * 0.24)) - ); - const [panelHighlightedLines, setPanelHighlightedLines] = useState>( - new Map() - ); - - type ResizeDragSnapshot = { - index: number | 'editor' | 'output'; - startX: number; - startY: number; - startEditorWidth: number; - startPanelWidth: number; - startOutputHeight: number; - /** Widest the dragged pane may get before it squeezes the elastic - * last column under `MIN_PANEL_WIDTH`. Fixed for the whole drag — - * only the dragged pane's own width changes. */ - maxPaneWidth: number; - }; - - const containerRef = useRef(null); - const editorRef = useRef(null); - const editorViewRef = useRef(null); - const resizeDragRef = useRef(null); - const memDiaResizeRef = useRef<{ paneId: string; startY: number; startHeight: number } | null>( - null + const panels = useTranslationPanels(getTranslation); + const exec = useEditorExecution({ + code, + sourceLang, + panels: panels.panels, + parseCode, + getTranslation, + }); + const layout = useEditorLayout({ + panels: panels.panels, + setPanels: panels.setPanels, + rootPanels: panels.rootPanels, + showAiSidePanel, + }); + const memDia = useMemDiaPanes(); + const menus = useEditorMenus(); + const aiSelection = useAiSelection(); + const { textSize, setTextSize, fontSizePx } = useTextSize(); + const onCreateEditor = useHighlightedEditor(exec.highlightedSourceLines); + + usePanelSourceMaps(exec.ast, panels.setPanels, getTranslation); + useEmbedLinkImport({ setCode, setSourceLang, setPanels: panels.setPanels }); + usePersistEditorState( + { + code, + sourceLang, + openLangs: panels.panels.map((panel) => panel.lang), + showAiChat: showAiSidePanel, + showMemDia, + }, + !loadedViaEmbedLink ); - const aiResizeRef = useRef<{ startX: number; startWidth: number } | null>(null); - const runTimeoutRef = useRef(null); - - useEffect(() => { - return () => { - if (runTimeoutRef.current !== null) { - clearTimeout(runTimeoutRef.current); - } - }; - }, []); - - const isMobile = viewportWidth < MOBILE_BREAKPOINT; - - const rootPanels = useMemo(() => panels.filter((panel) => !panel.stackedUnder), [panels]); - const rootPanelCount = rootPanels.length; - // The rightmost column stretches to fill whatever the panes to its left - // leave over, so dragging any divider can never expose a bare strip of the - // row's background. Its stored `width` goes unused while it holds this spot. - const elasticPanelId = rootPanelCount > 0 ? rootPanels[rootPanelCount - 1].id : null; - - const getMemDiaHeight = (paneId: string) => memDiaHeights[paneId] ?? 160; - - const getDefaultPanelWidth = useCallback(() => { - if (isMobile) { - return Math.max(MIN_PANEL_WIDTH, Math.floor(viewportWidth * 0.8)); - } - return 350; - }, [isMobile, viewportWidth]); - - const getMaxSourceWidth = useCallback(() => { - const reservedWidth = - ADD_STRIP_WIDTH + - (showAiSidePanel ? aiPanelWidth : 0) + - (panels.length > 0 ? MIN_PANEL_WIDTH : 0); - - return Math.max(MIN_SOURCE_WIDTH, viewportWidth - reservedWidth); - }, [aiPanelWidth, panels.length, showAiSidePanel, viewportWidth]); - - const getContentAvailableWidth = useCallback(() => { - return Math.max( - MIN_SOURCE_WIDTH, - viewportWidth - ADD_STRIP_WIDTH - (showAiSidePanel ? aiPanelWidth : 0) - ); - }, [aiPanelWidth, showAiSidePanel, viewportWidth]); - - const onMemDiaResizeMouseDown = (e: MouseEvent, paneId: string) => { - e.preventDefault(); - memDiaResizeRef.current = { - paneId, - startY: e.clientY, - startHeight: getMemDiaHeight(paneId), - }; - setResizingMemDiaPaneId(paneId); - }; + useEditorShortcuts({ + isDebugging: exec.isDebugging, + isDebugComplete: exec.isDebugComplete, + onRun: exec.run, + onDebugStart: exec.debugStart, + onDebugStep: exec.debugStep, + onDebugStop: exec.debugStop, + }); /** Switches the source pane to `lang` with the given code and a clean slate. */ - const applySourceLanguage = useCallback( - (lang: SupportedLang, newCode: string) => { - setSourceLang(lang); - setCode(newCode); - setIsDebugging(false); - setIsDebugComplete(false); - setHighlightedSourceLines([]); - setPanelHighlightedLines(new Map()); - setError(null); - }, - [setIsDebugging, setIsDebugComplete, setHighlightedSourceLines] - ); + const applySourceLanguage = (lang: SupportedLang, newCode: string) => { + setSourceLang(lang); + setCode(newCode); + exec.stopSession(); + exec.setError(null); + }; const handleSourceLanguageChange = (lang: SupportedLang) => { - setShowSourceLangDropdown(false); + menus.closeSourceLangDropdown(); if (lang === sourceLang) return; - try { - const sourceForTranslation = sourceLang === 'ast' ? 'python' : sourceLang; - // An empty program — blank text, or e.g. a Blocks workspace whose JSON - // holds no blocks — has nothing to translate; switch straight away. - const parsed = code.trim() ? parseCode(sourceForTranslation, code) : null; - if (!parsed || parsed.body.length === 0) { - applySourceLanguage(lang, ''); - return; - } - - const translated = getTranslation(parsed, lang).code; - let translationFailed = - !translated?.trim() || - translated.startsWith('// Translation to') || - translated.startsWith('// Valid source code required'); - - if (!translationFailed) { - try { - parseCode(lang, translated); - } catch { - translationFailed = true; - } - } - - if (translationFailed) { - // Can't produce a working translation — ask before discarding the - // program (the ConfirmModal rendered below handles the answer). - setPendingLangSwitch(lang); - return; - } - - applySourceLanguage(lang, translated); - } catch { - // The current source doesn't parse, so it can't be translated either. + const plan = planLanguageSwitch(code, sourceLang, lang, parseCode, getTranslation); + if (plan.kind === 'untranslatable') { + // Can't produce a working translation — ask before discarding the + // program (EditorDialogs handles the answer). setPendingLangSwitch(lang); - } - }; - - useEffect(() => { - const codeParam = searchParams.get('code'); - const targetLangParam = searchParams.get('targetLang'); - - if (!codeParam) return; - - try { - const decoded = decodeEmbed(codeParam); - if (!decoded) return; - - setCode(decoded.code); - setSourceLang(decoded.lang as SupportedLang); - - setPanels((prev) => { - const next = prev.filter((panel) => panel.lang !== decoded.lang); - const isValidTarget = [ - 'python', - 'java', - 'csp', - 'praxis', - 'javascript', - 'blocks', - 'ast', - ].includes(targetLangParam ?? ''); - - if (!isValidTarget || !targetLangParam || targetLangParam === decoded.lang) { - return next; - } - - if (next.some((panel) => panel.lang === targetLangParam)) { - return next; - } - - const defaultPanelWidth = - window.innerWidth < MOBILE_BREAKPOINT - ? Math.max(MIN_PANEL_WIDTH, Math.floor(window.innerWidth * 0.8)) - : 350; - - return [ - ...next, - { - id: randomId(), - lang: targetLangParam as SupportedLang, - width: defaultPanelWidth, - sourceMap: new Map(), - }, - ]; - }); - } catch (e) { - console.error('Failed to decode embed:', e); - } - }, [searchParams]); - - // Persist source code, language, and panel toggles so a reload (or a - // later visit) picks up where the user left off. Skipped for embed - // links — those are an explicit one-off share, not a session to save. - useEffect(() => { - if (loadedViaEmbedLink) return; - - const id = window.setTimeout(() => { - const state: PersistedEditorState = { - code, - sourceLang, - openLangs: panels.map((panel) => panel.lang), - showAiChat: showAiSidePanel, - showMemDia, - }; - localStorage.setItem(EDITOR_STATE_KEY, JSON.stringify(state)); - }, 250); - - return () => clearTimeout(id); - }, [code, sourceLang, panels, showAiSidePanel, showMemDia, loadedViaEmbedLink]); - - useEffect(() => { - const handleResize = () => { - setViewportWidth(window.innerWidth); - }; - - window.addEventListener('resize', handleResize); - return () => { - window.removeEventListener('resize', handleResize); - }; - }, []); - - useEffect(() => { - const maxSourceWidth = getMaxSourceWidth(); - const maxOutputHeight = Math.max(MIN_OUTPUT_HEIGHT, window.innerHeight - 120); - const maxAiWidth = Math.min( - MAX_AI_PANEL_WIDTH, - Math.max(MIN_AI_PANEL_WIDTH, viewportWidth - MIN_SOURCE_WIDTH - ADD_STRIP_WIDTH - 40) - ); - - setEditorWidth((prev) => clamp(prev, MIN_SOURCE_WIDTH, maxSourceWidth)); - setOutputHeight((prev) => clamp(prev, MIN_OUTPUT_HEIGHT, maxOutputHeight)); - setAiPanelWidth((prev) => clamp(prev, MIN_AI_PANEL_WIDTH, maxAiWidth)); - setPanels((prev) => { - let changed = false; - const next = prev.map((panel) => { - const clampedWidth = Math.max(MIN_PANEL_WIDTH, panel.width); - if (clampedWidth !== panel.width) { - changed = true; - return { ...panel, width: clampedWidth }; - } - return panel; - }); - - return changed ? next : prev; - }); - }, [getMaxSourceWidth, viewportWidth]); - - // Re-split the row evenly when the number of side-by-side panes changes, or - // when the space they share does. Keyed on the pane *count*, not on `panels` - // itself: that array gets rebuilt on every keystroke (source maps are - // refreshed per AST), and depending on it here undid each resize drag the - // frame after it happened. - useEffect(() => { - const availableWidth = getContentAvailableWidth(); - - if (rootPanelCount === 0) { - setEditorWidth((prev) => (prev === availableWidth ? prev : availableWidth)); return; } - const totalPaneCount = rootPanelCount + 1; - const targetPanelWidth = Math.max(MIN_PANEL_WIDTH, Math.floor(availableWidth / totalPaneCount)); - // The source pane absorbs the rounding remainder so the row fills exactly. - const targetSourceWidth = Math.max( - MIN_SOURCE_WIDTH, - availableWidth - targetPanelWidth * rootPanelCount - ); - - setEditorWidth((prev) => (prev === targetSourceWidth ? prev : targetSourceWidth)); - - setPanels((prev) => { - let changed = false; - const next = prev.map((panel) => { - if (panel.width === targetPanelWidth) return panel; - changed = true; - // Stacked panels are sized too: they render at their column's width, so - // an un-synced one would jump if it later swapped places with its root. - return { ...panel, width: targetPanelWidth }; - }); - - return changed ? next : prev; - }); - }, [getContentAvailableWidth, rootPanelCount]); - - const handleCreateEditor = useCallback((view: EditorView) => { - editorViewRef.current = view; - }, []); - - // Track the current source-editor selection so the user can chat about it. - const handleEditorUpdate = useCallback((update: ViewUpdate) => { - if (!update.selectionSet && !update.docChanged && !update.geometryChanged) return; - const sel = update.state.selection.main; - if (sel.empty) { - setAiSelection(''); - setAiSelectionCoords(null); - return; - } - setAiSelection(update.state.sliceDoc(sel.from, sel.to)); - const coords = update.view.coordsAtPos(sel.to); - setAiSelectionCoords(coords ? { top: coords.bottom + 4, left: coords.left } : null); - }, []); - - const clearAiSelection = useCallback(() => { - setAiSelection(''); - setAiSelectionCoords(null); - }, []); - - useEffect(() => { - dispatchLineHighlighting(editorViewRef, highlightedSourceLines); - }, [highlightedSourceLines]); - - useEffect(() => { - if (sourceLang === 'ast') return; - - try { - const program = parseCode(sourceLang, code); - setAst(program); - setError(null); - } catch (e: any) { - setAst(null); - setError(e.message); - } - }, [code, sourceLang, parseCode]); - - useEffect(() => { - if (!ast || panels.length === 0) return; - - setPanels((prev) => - prev.map((panel) => { - const { sourceMap } = getTranslation(ast, panel.lang); - return { ...panel, sourceMap }; - }) - ); - }, [ast, getTranslation]); - - // Gather every open code panel (source + translations) so the AI panel can - // send all of them as context, not just the source. - const aiPanelContext = useMemo(() => { - const result: LlmPanel[] = []; - if (code.trim()) { - if (sourceLang === 'blocks') { - // Blocks source is Blockly workspace JSON — meaningless to the LLM. - // Send the equivalent Praxis pseudocode string instead. - const readable = ast ? getTranslation(ast, 'praxis').code : ''; - if (readable.trim()) result.push({ language: 'praxis', code: readable }); - } else { - result.push({ language: sourceLang, code }); - } - } - if (ast) { - for (const panel of panels) { - // A blocks panel shows the same program the source already covers, - // and its JSON would only waste the model's context. - if (panel.lang === 'blocks') continue; - try { - const translated = getTranslation(ast, panel.lang).code; - if (translated?.trim()) { - result.push({ language: panel.lang, code: translated }); - } - } catch { - // Skip panels that can't currently be translated. - } - } - } - return result; - }, [code, sourceLang, ast, panels, getTranslation]); - - const executeRun = () => { - try { - const runLang = sourceLang === 'ast' ? 'python' : sourceLang; - const program = parseCode(runLang as SupportedLang, code); - if (!program) return; - - setAst(program); - - const debuggerInstance = new Debugger(); - debuggerInstance.init(program, runLang as SupportedLang, code); - - let result = debuggerInstance.step(); - while (result && !result.isComplete) { - setOutput(result.output); - - if (result.waitingForInput) { - setCurrentInterpreter(debuggerInstance); - setWaitingForNormalInput(true); - setNormalModeInputPrompt(result.inputPrompt || ''); - return; - } - - result = debuggerInstance.step(); - } - - if (result) { - setOutput(result.output); - } - - setCurrentInterpreter(null); - } catch (e: any) { - console.error(e); - setError(e.message); - setOutput((prev) => [...prev, `Error: ${e.message}`]); - } - }; - - const handleRun = () => { - setError(null); - setOutput([]); - setHasRun(true); - setWaitingForNormalInput(false); - - if (runTimeoutRef.current !== null) { - clearTimeout(runTimeoutRef.current); - } - runTimeoutRef.current = window.setTimeout(() => { - runTimeoutRef.current = null; - executeRun(); - }, 100); - }; - - const handleDebugStart = () => { - setError(null); - setOutput([]); - setHasRun(true); - - try { - const runLang = sourceLang === 'ast' ? 'python' : sourceLang; - const program = parseCode(runLang as SupportedLang, code); - if (!program) return; - - setAst(program); - initDebugger(program, runLang as SupportedLang, code); - setOutput(['Debugger initialized. Click Step to begin.']); - setHighlightedSourceLines([]); - setPanelHighlightedLines(new Map()); - } catch (e: any) { - console.error(e); - setError(e.message); - } + applySourceLanguage(lang, plan.kind === 'translated' ? plan.code : ''); }; - const handleDebugStep = () => { - if (!ast) return; - - try { - const result = stepDebugger(ast, code, sourceLang === 'ast' ? 'python' : sourceLang); - if (!result) return; - - setHighlightedSourceLines(result.sourceHighlightedLines); - - const panelHighlights = computeMultiplePanelHighlighting( - ast, - panels, - getTranslation, - result.step?.nodeId - ); - setPanelHighlightedLines(panelHighlights); - setOutput(result.outputLines); - - if (result.isComplete) { - setIsDebugComplete(true); - setOutput((prev) => [...prev, 'Execution complete.']); - } - } catch (e: any) { - console.error(e); - setError(e.message); - setOutput((prev) => [...prev, `Error: ${e.message}`]); - setIsDebugComplete(true); - } - }; - - const handleDebugStop = () => { - stopDebugger(); - setIsDebugging(false); - setHighlightedSourceLines([]); - setPanelHighlightedLines(new Map()); - setOutput((prev) => [...prev, 'Debugger stopped.']); - }; - - const handleSubmitInput = (input: string) => { - provideInput(input); - - if (!ast) return; - - setTimeout(() => { - const result = stepDebugger(ast, code, sourceLang === 'ast' ? 'python' : sourceLang); - if (!result) return; - - setHighlightedSourceLines(result.sourceHighlightedLines); - - const panelHighlights = computeMultiplePanelHighlighting( - ast, - panels, - getTranslation, - result.step?.nodeId - ); - setPanelHighlightedLines(panelHighlights); - // outputLines is the cumulative program output, so replace rather than append. - setOutput(result.outputLines); - - if (result.isComplete) { - setIsDebugComplete(true); - setOutput((prev) => [...prev, 'Execution complete.']); - } - }, 0); - }; - - const handleNormalModeInputSubmit = (input: string) => { - if (!currentInterpreter) return; - - try { - currentInterpreter.provideInput(input); - - let steps = currentInterpreter.step(); - let cumulativeOutput: string[] = steps?.output || []; - - while (steps && !steps.isComplete) { - if (steps.waitingForInput) { - setWaitingForNormalInput(true); - setNormalModeInputPrompt(steps.inputPrompt || ''); - setOutput(cumulativeOutput); - return; - } - - cumulativeOutput = [...(steps?.output || [])]; - setOutput(cumulativeOutput); - steps = currentInterpreter.step(); - } - - if (steps) { - cumulativeOutput = [...(steps?.output || [])]; - setOutput(cumulativeOutput); - } - - setWaitingForNormalInput(false); - setNormalModeInputPrompt(''); - setCurrentInterpreter(null); - } catch (e: any) { - console.error('Error in input submit:', e); - setError(e.message); - setOutput((prev) => [...prev, `Error: ${e.message}`]); - setWaitingForNormalInput(false); - setCurrentInterpreter(null); + /** Replaces the editor's contents, resetting the console and any session. */ + const replaceProgram = (newCode: string, lang?: SupportedLang, keepHasRun = false) => { + if (lang) { + setSourceLang(lang); + // Drop any translation panel that duplicates the new source language. + panels.dropPanelsForLang(lang); } + setCode(newCode); + exec.clearConsole({ resetHasRun: !keepHasRun }); + exec.stopSession(); }; - const handleClear = () => { - setCode(''); - setAst(null); - setOutput([]); - setError(null); - setHasRun(false); - }; + // "Open in editor" on an AI chat code block. The fence's language tag + // switches the source language when it names one Praxly supports. + useOpenCodeBridge((newCode, lang) => replaceProgram(newCode, lang, true)); const loadExample = (exampleId: string) => { const example = getExampleById(exampleId); if (!example) return; - - setSourceLang(example.lang); - setCode(example.code); - // Drop any translation panel that duplicates the example's own language. - setPanels((prev) => prev.filter((panel) => panel.lang !== example.lang)); - setOutput([]); - setError(null); - setHasRun(false); - setIsDebugging(false); - setIsDebugComplete(false); - setHighlightedSourceLines([]); - setPanelHighlightedLines(new Map()); - setWaitingForNormalInput(false); - setCurrentInterpreter(null); + replaceProgram(example.code, example.lang); }; const handleLoadExample = (exampleId: string) => { - setShowSettingsMenu(false); - setShowExamplesMenu(false); + menus.closeHeaderMenus(); // A non-blank editor is about to be discarded — confirm first. if (code.trim()) { setPendingExampleId(exampleId); @@ -753,22 +163,11 @@ export default function EditorPage() { const loadDemo = () => { const demo = getDemoForLang(sourceLang); if (!demo) return; - - setCode(demo); - setOutput([]); - setError(null); - setHasRun(false); - setIsDebugging(false); - setIsDebugComplete(false); - setHighlightedSourceLines([]); - setPanelHighlightedLines(new Map()); - setWaitingForNormalInput(false); - setCurrentInterpreter(null); + replaceProgram(demo); }; const handleLoadDemo = () => { - setShowSettingsMenu(false); - setShowExamplesMenu(false); + menus.closeHeaderMenus(); // A non-blank editor is about to be discarded — confirm first. if (code.trim()) { setPendingDemoLoad(true); @@ -777,81 +176,16 @@ export default function EditorPage() { loadDemo(); }; - const handleToggleAiPanel = () => { - setShowAiSidePanel((prev) => !prev); + const handleClear = () => { + setCode(''); + exec.setAst(null); + exec.clearConsole({ resetHasRun: true }); }; const handleToggleMemDia = () => { - setShowMemDia((prev) => { - if (!prev) setMemDiaStates(new Map()); // Reset all closed panes when enabling - return !prev; - }); - }; - - // "Open in editor" on AI chat code blocks: replaces the source editor's - // contents (same reset pattern as loading an example). The fence's language - // tag switches the source language when it names one Praxly supports. - useEffect(() => { - const fenceLangMap: Record = { - praxis: 'praxis', - python: 'python', - py: 'python', - java: 'java', - csp: 'csp', - javascript: 'javascript', - js: 'javascript', - }; - useEditorBridge.getState().setOpenCode((newCode, fenceLang) => { - const lang = fenceLangMap[fenceLang.trim().toLowerCase()]; - if (lang) { - setSourceLang(lang); - setPanels((prev) => prev.filter((panel) => panel.lang !== lang)); - } - setCode(newCode); - setOutput([]); - setError(null); - setIsDebugging(false); - setIsDebugComplete(false); - setHighlightedSourceLines([]); - setPanelHighlightedLines(new Map()); - setWaitingForNormalInput(false); - setCurrentInterpreter(null); - }); - return () => useEditorBridge.getState().setOpenCode(null); - }, [setIsDebugging, setIsDebugComplete, setHighlightedSourceLines]); - - // Shared by both AI-panel resize handles (the panel's left edge and the - // left edge of the add-panel strip): they move in lockstep because the - // strip has a fixed width, so one drag math works for both. - const handleStartAiResize = useCallback( - (e: MouseEvent) => { - e.preventDefault(); - aiResizeRef.current = { - startX: e.clientX, - startWidth: aiPanelWidth, - }; - setIsResizingAiPanel(true); - }, - [aiPanelWidth] - ); - - const handleTextSizeChange = (size: TextSize) => { - setTextSize(size); - localStorage.setItem('praxly-text-size', String(size)); - }; - - const getMemDiaState = (paneId: string): 'open' | 'closed' => memDiaStates.get(paneId) ?? 'open'; - - const cycleMemDiaState = (paneId: string) => { - setMemDiaStates((prev) => { - const next = new Map(prev); - next.set(paneId, (next.get(paneId) ?? 'open') === 'open' ? 'closed' : 'open'); - return next; - }); - }; - - const cycleOutputState = () => { - setOutputState((prev) => (prev === 'open' ? 'closed' : 'open')); + // Reopen every collapsed pane when the diagrams come back on. + if (!showMemDia) memDia.resetStates(); + setShowMemDia((prev) => !prev); }; const handleShare = async () => { @@ -861,327 +195,24 @@ export default function EditorPage() { }); const embedUrl = `${window.location.origin}/v2/embed?code=${encoded}`; - const success = await copyToClipboard(embedUrl); - if (success) { + if (await copyToClipboard(embedUrl)) { setEmbedCopied(true); setTimeout(() => setEmbedCopied(false), 2000); } }; - const getExtensions = (lang: SupportedLang) => { - const baseExtensions = getCodeMirrorExtensions(lang); - baseExtensions.push(highlightedLinesField); - return baseExtensions; - }; - - const addPanel = (lang: SupportedLang) => { - setPanels((prev) => { - if (prev.some((panel) => panel.lang === lang)) { - return prev; - } - - const translation = getTranslation(ast, lang); - - return [ - ...prev, - { - id: randomId(), - lang, - width: getDefaultPanelWidth(), - sourceMap: translation.sourceMap, - }, - ]; - }); - }; - - const removePanel = (id: string) => { - setPanels((prev) => removePanelById(prev, id)); - }; - - /** - * Toggles a translation panel on or off without closing the add menu. - */ - const togglePanel = (lang: SupportedLang) => { - const existing = panels.find((panel) => panel.lang === lang); - if (existing) { - removePanel(existing.id); - } else { - addPanel(lang); - } - }; - - const togglePanelStack = (id: string) => { - setPanels((prev) => toggleStack(prev, id)); - }; - - const handlePanelDragStart = (e: DragEvent, panelId: string) => { - setDraggedPanelId(panelId); - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('text/plain', panelId); - }; - - const handlePanelDragOver = (e: DragEvent, panelId: string) => { - if (!draggedPanelId || draggedPanelId === panelId) return; - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - if (dragOverPanelId !== panelId) { - setDragOverPanelId(panelId); - } - }; - - const handlePanelDrop = (e: DragEvent, panelId: string) => { - e.preventDefault(); - const sourceId = draggedPanelId || e.dataTransfer.getData('text/plain'); + const sourceExtensions = useMemo(() => { + const extensions = getCodeMirrorExtensions(sourceLang === 'ast' ? 'python' : sourceLang); + extensions.push(highlightedLinesField); + return extensions; + }, [sourceLang]); - if (!sourceId || sourceId === panelId) { - setDragOverPanelId(null); - setDraggedPanelId(null); - return; - } - - setPanels((prev) => - inSameColumn(prev, sourceId, panelId) - ? swapStacked(prev, sourceId, panelId) - : reorderPanel(prev, sourceId, panelId) - ); - - setDragOverPanelId(null); - setDraggedPanelId(null); - }; - - const handlePanelDragEnd = () => { - setDraggedPanelId(null); - setDragOverPanelId(null); - }; - - const onMouseDown = (e: MouseEvent, index: number | 'editor' | 'output') => { - e.preventDefault(); - - if (index === 'editor' && panels.length === 0) { - return; - } - - if (typeof index === 'number' && !panels[index]) { - return; - } - - // Everything the dragged pane can't claim: the other fixed-width columns, - // the source pane (when a panel is being dragged), and the minimum the - // elastic last column has to keep. - const draggedId = typeof index === 'number' ? panels[index].id : null; - const otherFixedWidth = rootPanels - .slice(0, -1) - .filter((panel) => panel.id !== draggedId) - .reduce((sum, panel) => sum + panel.width, 0); - - resizeDragRef.current = { - index, - startX: e.clientX, - startY: e.clientY, - startEditorWidth: editorWidth, - startPanelWidth: typeof index === 'number' ? panels[index].width : 0, - startOutputHeight: outputHeight, - maxPaneWidth: - getContentAvailableWidth() - - otherFixedWidth - - MIN_PANEL_WIDTH - - (index === 'editor' ? 0 : editorWidth), - }; - - setResizingIdx(index); - }; - - useEffect(() => { - const handleMouseMove = (e: globalThis.MouseEvent) => { - const drag = resizeDragRef.current; - if (!drag || resizingIdx === null) return; - - if (resizingIdx === 'output') { - const deltaY = e.clientY - drag.startY; - const maxOutputHeight = Math.max(MIN_OUTPUT_HEIGHT, window.innerHeight - 120); - const rawHeight = drag.startOutputHeight - deltaY; - if (rawHeight < MIN_OUTPUT_HEIGHT - 40) { - setOutputState('closed'); - } else { - const nextHeight = clamp(rawHeight, MIN_OUTPUT_HEIGHT, maxOutputHeight); - setOutputHeight(nextHeight); - setOutputState('open'); - } - } else if (resizingIdx === 'editor') { - const deltaX = e.clientX - drag.startX; - const nextWidth = clamp( - drag.startEditorWidth + deltaX, - MIN_SOURCE_WIDTH, - Math.max(MIN_SOURCE_WIDTH, drag.maxPaneWidth) - ); - setEditorWidth(nextWidth); - } else { - const panelIndex = resizingIdx; - setPanels((prev) => { - if (!prev[panelIndex]) { - return prev; - } - - const deltaX = e.clientX - drag.startX; - const newWidth = clamp( - drag.startPanelWidth + deltaX, - MIN_PANEL_WIDTH, - Math.max(MIN_PANEL_WIDTH, drag.maxPaneWidth) - ); - if (prev[panelIndex].width === newWidth) { - return prev; - } - - const next = [...prev]; - next[panelIndex] = { ...prev[panelIndex], width: newWidth }; - return next; - }); - } - }; - - const handleMouseUp = () => { - setResizingIdx(null); - resizeDragRef.current = null; - }; - - if (resizingIdx !== null) { - window.addEventListener('mousemove', handleMouseMove); - window.addEventListener('mouseup', handleMouseUp); - } - - return () => { - window.removeEventListener('mousemove', handleMouseMove); - window.removeEventListener('mouseup', handleMouseUp); - }; - }, [resizingIdx]); - - useEffect(() => { - const handleMouseMove = (e: globalThis.MouseEvent) => { - const drag = memDiaResizeRef.current; - if (!drag) return; - - const deltaY = e.clientY - drag.startY; - const rawHeight = drag.startHeight - deltaY; - - if (rawHeight < MIN_MEM_DIA_HEIGHT - 40) { - setMemDiaStates((prev) => { - const next = new Map(prev); - next.set(drag.paneId, 'closed'); - return next; - }); - } else { - const nextHeight = clamp(rawHeight, MIN_MEM_DIA_HEIGHT, MAX_MEM_DIA_HEIGHT); - setMemDiaHeights((prev) => - prev[drag.paneId] === nextHeight ? prev : { ...prev, [drag.paneId]: nextHeight } - ); - setMemDiaStates((prev) => { - if ((prev.get(drag.paneId) ?? 'open') === 'open') return prev; - const next = new Map(prev); - next.set(drag.paneId, 'open'); - return next; - }); - } - }; - - const handleMouseUp = () => { - memDiaResizeRef.current = null; - setResizingMemDiaPaneId(null); - }; - - if (resizingMemDiaPaneId) { - window.addEventListener('mousemove', handleMouseMove); - window.addEventListener('mouseup', handleMouseUp); - } - - return () => { - window.removeEventListener('mousemove', handleMouseMove); - window.removeEventListener('mouseup', handleMouseUp); - }; - }, [resizingMemDiaPaneId]); - - useEffect(() => { - const handleMouseMove = (e: globalThis.MouseEvent) => { - const drag = aiResizeRef.current; - if (!isResizingAiPanel || !drag) return; - - const deltaX = e.clientX - drag.startX; - const maxAiWidth = Math.min( - MAX_AI_PANEL_WIDTH, - Math.max(MIN_AI_PANEL_WIDTH, viewportWidth - MIN_SOURCE_WIDTH - ADD_STRIP_WIDTH - 40) - ); - const nextWidth = clamp(drag.startWidth - deltaX, MIN_AI_PANEL_WIDTH, maxAiWidth); - - setAiPanelWidth(nextWidth); - }; - - const handleMouseUp = () => { - aiResizeRef.current = null; - setIsResizingAiPanel(false); - }; - - if (isResizingAiPanel) { - window.addEventListener('mousemove', handleMouseMove); - window.addEventListener('mouseup', handleMouseUp); - } - - return () => { - window.removeEventListener('mousemove', handleMouseMove); - window.removeEventListener('mouseup', handleMouseUp); - }; - }, [isResizingAiPanel, viewportWidth]); - - // Close each dropdown when the user clicks anywhere outside it. - useClickOutside(showSourceLangDropdown, '.source-lang-dropdown', () => - setShowSourceLangDropdown(false) + // Every open code pane, so the AI panel sends the whole workspace as context. + const aiPanelContext = useMemo( + () => buildAiPanelContext(code, sourceLang, exec.ast, panels.panels, getTranslation), + [code, sourceLang, exec.ast, panels.panels, getTranslation] ); - useClickOutside(showSettingsMenu, '.settings-dropdown', () => setShowSettingsMenu(false)); - useClickOutside(showExamplesMenu, '.examples-dropdown', () => setShowExamplesMenu(false)); - - // F5/F10 shortcuts. The handlers close over fresh state each render, so we - // read them through a ref — the listener itself is registered only once. - const keyActionsRef = useRef({ - handleRun, - handleDebugStart, - handleDebugStep, - handleDebugStop, - isDebugging, - isDebugComplete, - }); - keyActionsRef.current = { - handleRun, - handleDebugStart, - handleDebugStep, - handleDebugStop, - isDebugging, - isDebugComplete, - }; - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - const actions = keyActionsRef.current; - if (e.key === 'F5') { - e.preventDefault(); - if (e.shiftKey) { - if (actions.isDebugging) actions.handleDebugStop(); - else actions.handleDebugStart(); - } else { - if (!actions.isDebugging) actions.handleRun(); - } - } else if (e.key === 'F10' && actions.isDebugging && !actions.isDebugComplete) { - e.preventDefault(); - actions.handleDebugStep(); - } - }; - - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, []); - - const sourcePaneWidth = panels.length === 0 ? getContentAvailableWidth() : editorWidth; - - // Settings → text size, in px. CodeMirror reads it via the CSS variable; - // Blockly panes take the numeric value to re-theme their SVG text. - const fontSizePx = 10 + textSize * 2; const fontSize = `${fontSizePx}px`; return ( @@ -1199,10 +230,10 @@ export default function EditorPage() { { - setShowExamplesMenu((prev) => !prev); - setShowSettingsMenu(false); - }} - onToggleSettingsMenu={() => { - setShowSettingsMenu((prev) => !prev); - setShowExamplesMenu(false); - }} - onDebugStart={handleDebugStart} - onRun={handleRun} - onDebugStep={handleDebugStep} - onDebugStop={handleDebugStop} - onTextSizeChange={handleTextSizeChange} + onToggleExamplesMenu={menus.toggleExamplesMenu} + onToggleSettingsMenu={menus.toggleSettingsMenu} + onDebugStart={exec.debugStart} + onRun={exec.run} + onDebugStep={exec.debugStep} + onDebugStop={exec.debugStop} + onTextSizeChange={setTextSize} />
panels.togglePanel(lang, exec.ast)} + onToggleAiPanel={() => setShowAiSidePanel((prev) => !prev)} onToggleMemDia={handleToggleMemDia} />
-
+
cycleMemDiaState('source')} - onToggleSourceLangDropdown={() => setShowSourceLangDropdown((prev) => !prev)} + memDiaState={memDia.getState('source')} + onToggleMemDiaCollapse={() => memDia.toggle('source')} + onToggleSourceLangDropdown={menus.toggleSourceLangDropdown} onSelectSourceLang={handleSourceLanguageChange} onCodeChange={(value) => { setCode(value); - if (isDebugging) { - setHighlightedSourceLines([]); - setPanelHighlightedLines(new Map()); - } + exec.noteCodeEdited(); }} - onCreateEditor={handleCreateEditor} - onEditorUpdate={handleEditorUpdate} - onMemDiaResizeMouseDown={onMemDiaResizeMouseDown} + onCreateEditor={onCreateEditor} + onEditorUpdate={aiSelection.onEditorUpdate} + onMemDiaResizeMouseDown={memDia.startResize} onResizeEditor={(e) => { - if (panels.length === 0) { - return; - } - onMouseDown(e, 'editor'); + if (panels.panels.length > 0) layout.startPaneResize(e, 'editor'); }} - editorResizeActive={panels.length > 0 && resizingIdx === 'editor'} + editorResizeActive={panels.panels.length > 0 && layout.resizingIdx === 'editor'} />
- {/* Render root panels as columns; each column may have a stacked child below */} - {rootPanels.map((rootPanel) => { - const rootIdx = panels.indexOf(rootPanel); - const stackedPanel = panels.find((p) => p.stackedUnder === rootPanel.id); - // The last column fills the leftover width instead of taking a - // fixed one, so there is nothing to its right to resize against. - // (On mobile the row scrolls horizontally, so it stays fixed.) - const isElastic = rootPanel.id === elasticPanelId && !isMobile; - const onResizeColumn = isElastic - ? undefined - : (e: MouseEvent) => onMouseDown(e, rootIdx); - // "Stack below" button is shown on a root panel when: - // - it has no stacked child yet (column isn't full) - // - there is at least one other root panel that also has no stacked child - // (so this panel could move below that one) - const otherFreeRoots = panels.filter( - (p) => - !p.stackedUnder && - p.id !== rootPanel.id && - !panels.some((sp) => sp.stackedUnder === p.id) - ); - const canStack = !stackedPanel && otherFreeRoots.length > 0; - - return ( -
- {/* Top panel (root) */} -
- togglePanelStack(rootPanel.id) : undefined - } - isStacked={false} - onRemovePanel={removePanel} - onResize={onResizeColumn} - onMemDiaResizeMouseDown={onMemDiaResizeMouseDown} - onToggleMemDiaCollapse={() => cycleMemDiaState(rootPanel.id)} - onPanelDragStart={handlePanelDragStart} - onPanelDragOver={handlePanelDragOver} - onPanelDrop={handlePanelDrop} - onPanelDragEnd={handlePanelDragEnd} - /> -
- - {/* Bottom panel (stacked child) — same flex-1 height, shares column width */} - {stackedPanel && ( -
- togglePanelStack(stackedPanel.id)} - isStacked={true} - onRemovePanel={removePanel} - onResize={onResizeColumn} - onMemDiaResizeMouseDown={onMemDiaResizeMouseDown} - onToggleMemDiaCollapse={() => cycleMemDiaState(stackedPanel.id)} - onPanelDragStart={handlePanelDragStart} - onPanelDragOver={handlePanelDragOver} - onPanelDrop={handlePanelDrop} - onPanelDragEnd={handlePanelDragEnd} - /> -
- )} -
- ); - })} + getTranslation(exec.ast, lang).code} + fontSize={fontSizePx} + panelHighlightedLines={exec.panelHighlightedLines} + showMemDia={showMemDia} + memDiaResizingPaneId={memDia.resizingPaneId} + getMemDiaHeight={memDia.getHeight} + getMemDiaState={memDia.getState} + onToggleMemDia={memDia.toggle} + onMemDiaResizeMouseDown={memDia.startResize} + currentVariables={exec.currentVariables} + resizingIdx={layout.resizingIdx} + onResizeColumn={layout.startPaneResize} + onRemovePanel={panels.removePanel} + onToggleStack={panels.togglePanelStack} + dragHandlers={panels.dragHandlers} + />
onMouseDown(e, 'output')} - waitingForInput={waitingForInput || waitingForNormalInput} - inputPrompt={inputPrompt || normalModeInputPrompt} - onSubmitInput={(input) => { - if (waitingForNormalInput) { - handleNormalModeInputSubmit(input); - } else { - handleSubmitInput(input); - } - }} - panelState={outputState} - onToggle={cycleOutputState} - onOpen={() => setOutputState('open')} + output={exec.output} + error={exec.error} + hasRun={exec.hasRun} + variables={exec.currentVariables} + callStack={exec.currentCallStack} + showVariables={exec.isDebugging} + height={layout.outputState === 'open' ? layout.outputHeight : undefined} + resizeActive={layout.resizingIdx === 'output'} + onResize={(e) => layout.startPaneResize(e, 'output')} + waitingForInput={exec.waitingForInput} + inputPrompt={exec.inputPrompt} + onSubmitInput={exec.submitInput} + panelState={layout.outputState} + onToggle={layout.toggleOutputState} + onOpen={layout.openOutput} />
{showAiSidePanel && ( setShowAiSidePanel(false)} panels={aiPanelContext} - selection={aiSelection} - onClearSelection={clearAiSelection} - /> - )} - - {/* Asks before discarding a program that can't be translated. */} - {pendingLangSwitch && ( - { - applySourceLanguage(pendingLangSwitch, ''); - setPendingLangSwitch(null); - }} - onCancel={() => setPendingLangSwitch(null)} - /> - )} - - {/* Asks before replacing a non-blank editor with an example program. */} - {pendingExampleId && ( - { - loadExample(pendingExampleId); - setPendingExampleId(null); - }} - onCancel={() => setPendingExampleId(null)} + selection={aiSelection.selection} + onClearSelection={aiSelection.clear} /> )} - {/* Asks before replacing a non-blank editor with the demo program. */} - {pendingDemoLoad && ( - { - loadDemo(); - setPendingDemoLoad(false); - }} - onCancel={() => setPendingDemoLoad(false)} - /> - )} + { + applySourceLanguage(lang, ''); + setPendingLangSwitch(null); + }} + onCancelLangSwitch={() => setPendingLangSwitch(null)} + onConfirmExample={(exampleId) => { + loadExample(exampleId); + setPendingExampleId(null); + }} + onCancelExample={() => setPendingExampleId(null)} + onConfirmDemo={() => { + loadDemo(); + setPendingDemoLoad(false); + }} + onCancelDemo={() => setPendingDemoLoad(false)} + /> {/* Highlight-to-chat: floating button near the editor selection. */} - {aiSelection && aiSelectionCoords && ( - + {aiSelection.selection && aiSelection.coords && ( + setShowAiSidePanel(true)} /> )}
diff --git a/src/pages/EmbedPage.tsx b/src/pages/EmbedPage.tsx index c2e0e46..08159fd 100644 --- a/src/pages/EmbedPage.tsx +++ b/src/pages/EmbedPage.tsx @@ -1,529 +1,103 @@ /** - * EmbedPage component that provides an embeddable code editor interface. - * Similar to EditorPage but optimized for sharing and embedding in external websites. + * Embeddable code player: a ready-to-run version of the editor for dropping + * into an external page. `?code=` carries the program; adding `?to=` + * switches from the simple source+output layout to source+translation over + * output. + * + * This file is layout only — the panes live in `components/embed/`, and running + * / debugging / parsing lives in `useEmbedExecution`. */ -import { useState, useCallback, useEffect, useId, useRef } from 'react'; -import { useSearchParams } from 'react-router-dom'; -import { Play, AlertCircle, FastForward, Square, ChevronDown } from 'lucide-react'; +import { useState } from 'react'; +import { useSearchParams } from 'react-router'; -import { decodeEmbed, encodeEmbed, type EmbedData } from '../utils/embedCodec'; -import type { Program } from '../language/ast'; import type { SupportedLang } from '../components/LanguageSelector'; -import { HighlightableCodeMirror } from '../components/HighlightableCodeMirror'; -import { JSONTree } from '../components/JSONTree'; -import CodeMirror from '@uiw/react-codemirror'; -import { vscodeDark } from '@uiw/codemirror-theme-vscode'; -import { EditorView } from '@codemirror/view'; -import { getCodeMirrorExtensions } from '../utils/editorUtils'; -import { highlightedLinesField, dispatchLineHighlighting } from '../utils/codemirrorConfig'; -import { useCodeParsing } from '../hooks/useCodeParsing'; -import { useCodeDebugger } from '../hooks/useCodeDebugger'; -import { Debugger } from '../language/debugger'; -import { VariableFrames } from '../components/VariableFrames'; +import { EmbedActions } from '../components/embed/EmbedActions'; +import { EmbedErrorScreen } from '../components/embed/EmbedErrorScreen'; +import { EmbedOutput } from '../components/embed/EmbedOutput'; +import { EmbedSourcePane } from '../components/embed/EmbedSourcePane'; +import { EmbedTranslationPane } from '../components/embed/EmbedTranslationPane'; +import { encodeEmbed } from '../utils/embedCodec'; +import { useEmbedData } from '../hooks/useEmbedData'; +import { useDragWidth } from '../hooks/useDragWidth'; +import { useEmbedExecution } from '../hooks/useEmbedExecution'; const VALID_TO_LANGS = ['python', 'java', 'csp', 'praxis', 'javascript', 'ast']; +const MIN_SOURCE_WIDTH = 150; + export default function EmbedPage() { const [searchParams] = useSearchParams(); - // Determine layout mode from ?to= URL param + // Layout mode comes from ?to= — absent or unrecognised means source+output. const toParam = searchParams.get('to'); const isTranslationMode = toParam !== null && VALID_TO_LANGS.includes(toParam); - const [embedData, setEmbedData] = useState(null); - const [output, setOutput] = useState([]); - const [ast, setAst] = useState(null); - const [error, setError] = useState(null); - - // Layout state - const [sourceWidth, setSourceWidth] = useState(window.innerWidth / 2); - const [resizingIdx, setResizingIdx] = useState<'source' | null>(null); + const { embedData, decodeError, setCode } = useEmbedData(); - // Translation state (initialized from ?to= param when in translation mode) - const [currentTargetLang, setCurrentTargetLang] = useState( + // Translation-pane selection, seeded from ?to=. + const [targetLang, setTargetLang] = useState( isTranslationMode && toParam !== 'ast' ? (toParam as SupportedLang) : 'python' ); const [showTranslationMenu, setShowTranslationMenu] = useState(false); const [showAst, setShowAst] = useState(toParam === 'ast'); - // Get hooks for parsing and debugging - const { parseCode, getTranslation } = useCodeParsing(); const { - isDebugging, - setIsDebugging, - isDebugComplete, - setIsDebugComplete, - highlightedSourceLines, - setHighlightedSourceLines, - highlightedTranslationLines, - setHighlightedTranslationLines, - currentCallStack, - waitingForInput, - inputPrompt, - initDebugger, - stepDebugger, - stopDebugger, - provideInput, - } = useCodeDebugger(getTranslation); - - // Normal mode input handling - const [waitingForNormalInput, setWaitingForNormalInput] = useState(false); - const [normalModeInputPrompt, setNormalModeInputPrompt] = useState(''); - // The Debugger instance driving a non-debug run that paused for input(). - const [currentInterpreter, setCurrentInterpreter] = useState(null); - - const editorViewRef = useRef(null); - const stdinInputId = useId(); - - // Decode the embed data on mount - useEffect(() => { - // v2 format: ?code= - const code = searchParams.get('code'); - if (code) { - const decoded = decodeEmbed(code); - if (!decoded) { - setError('Failed to decode embed data'); - return; - } - setEmbedData(decoded); - return; - } - - // v1 format: #code= - const hash = window.location.hash; - if (hash.startsWith('#code=')) { - const v1Code = decodeURIComponent(hash.slice('#code='.length)); - setEmbedData({ code: v1Code, lang: 'praxis' }); - return; - } - - setError('No code provided in URL'); - }, [searchParams]); - - // Parse code when embed data changes - useEffect(() => { - if (!embedData) return; - - try { - const program = parseCode(embedData.lang as SupportedLang, embedData.code); - setAst(program); - setError(null); - } catch (e: any) { - setError(e.message); - setAst(null); - } - }, [embedData, parseCode]); - - // Handle line highlighting using CodeMirror decorations - useEffect(() => { - dispatchLineHighlighting(editorViewRef, highlightedSourceLines); - }, [highlightedSourceLines]); - - const handleCreateEditor = useCallback((view: EditorView) => { - editorViewRef.current = view; - }, []); - - const getExtensions = (lang: SupportedLang) => { - const baseExtensions = getCodeMirrorExtensions(lang); - baseExtensions.push(highlightedLinesField); - return baseExtensions; - }; - - const handleRun = () => { - setError(null); - setOutput([]); - setWaitingForNormalInput(false); - try { - const program = ast; - if (!program) return; - - // Use debugger-based approach from the start so we don't re-execute on input - const debugger_ = new Debugger(); - debugger_.init( - program, - (embedData?.lang || 'python') as SupportedLang, - embedData?.code || '' - ); - - // Run all steps until completion or input needed - let result = debugger_.step(); - - while (result && !result.isComplete) { - // result.output is already cumulative, so just set it directly - setOutput(result.output); - - // Check if waiting for input - if (result.waitingForInput) { - setCurrentInterpreter(debugger_); - setWaitingForNormalInput(true); - setNormalModeInputPrompt(result.inputPrompt || ''); - return; - } - - result = debugger_.step(); - } - - // Final step output - if (result) { - setOutput(result.output); - } - - setCurrentInterpreter(null); - } catch (e: any) { - console.error(e); - setError(e.message); - setOutput((prev) => [...prev, `Error: ${e.message}`]); - } - }; - - const handleDebugStart = () => { - setError(null); - setOutput([]); - try { - const program = ast; - if (!program) return; - - initDebugger(program, (embedData?.lang || 'python') as SupportedLang, embedData?.code || ''); - setOutput(['Debugger initialized. Click Step to begin.']); - } catch (e: any) { - console.error(e); - setError(e.message); - } - }; - - const handleDebugStep = () => { - if (!ast || !embedData?.code) return; - - try { - const result = stepDebugger(ast, embedData.code, currentTargetLang); - if (!result) return; - - setHighlightedSourceLines(result.sourceHighlightedLines); - setHighlightedTranslationLines(result.translationHighlightedLines); - setOutput(result.outputLines); - - if (result.isComplete) { - setIsDebugComplete(true); - setOutput((prev) => [...prev, 'Execution complete.']); - } - } catch (e: any) { - console.error(e); - setError(e.message); - setOutput((prev) => [...prev, `Error: ${e.message}`]); - setIsDebugComplete(true); - } - }; - - const handleDebugStop = () => { - stopDebugger(); - setIsDebugging(false); - setOutput((prev) => [...prev, 'Debugger stopped.']); - }; - - const handleSubmitInput = (input: string) => { - provideInput(input); - // Echo removed, handled by interpreter - - // Automatically continue execution after input is provided - if (ast && embedData?.code) { - setTimeout(() => { - const result = stepDebugger(ast, embedData.code, currentTargetLang); - if (!result) return; - - setHighlightedSourceLines(result.sourceHighlightedLines); - setHighlightedTranslationLines(result.translationHighlightedLines); - // outputLines is the cumulative program output, so replace rather than append. - setOutput(result.outputLines); - - if (result.isComplete) { - setIsDebugComplete(true); - setOutput((prev) => [...prev, 'Execution complete.']); - } - }, 0); - } - }; - - const handleNormalModeInputSubmit = (input: string) => { - if (!currentInterpreter) return; - - try { - // Provide input to the debugger FIRST, before stepping - currentInterpreter.provideInput(input); - - // Continue stepping until all remaining statements are executed - let steps = currentInterpreter.step(); - let cumulativeOutput: string[] = steps?.output || []; - - while (steps && !steps.isComplete) { - // Check if waiting for input - if (steps.waitingForInput) { - setWaitingForNormalInput(true); - setNormalModeInputPrompt(steps.inputPrompt || ''); - // Update output with what we have so far - cumulativeOutput = [...(steps?.output || [])]; - setOutput(cumulativeOutput); - return; - } - - // Update cumulative output from this step - cumulativeOutput = [...(steps?.output || [])]; - setOutput(cumulativeOutput); - - // Step to next - steps = currentInterpreter.step(); - } - - // Final output when complete - if (steps) { - cumulativeOutput = [...(steps?.output || [])]; - // Input echo is now handled by the interpreter - setOutput(cumulativeOutput); - } + width: sourceWidth, + isResizing, + startResize, + } = useDragWidth(window.innerWidth / 2, MIN_SOURCE_WIDTH); - setWaitingForNormalInput(false); - setNormalModeInputPrompt(''); - setCurrentInterpreter(null); - } catch (e: any) { - console.error('Error in input submit:', e); - setError(e.message); - setOutput((prev) => [...prev, `Error: ${e.message}`]); - setWaitingForNormalInput(false); - setCurrentInterpreter(null); - } - }; + const exec = useEmbedExecution(embedData, targetLang); const handleOpenInEditor = () => { if (!embedData) return; - const encoded = encodeEmbed({ - code: embedData.code, - lang: embedData.lang as any, - }); - if (isTranslationMode) { - const targetLang = showAst ? 'ast' : currentTargetLang; - window.open(`/v2/editor?code=${encoded}&targetLang=${targetLang}`, '_blank'); - } else { - window.open(`/v2/editor?code=${encoded}`, '_blank'); - } - }; - - // Resize handler - const onMouseDown = (e: React.MouseEvent) => { - setResizingIdx('source'); - e.preventDefault(); + const encoded = encodeEmbed({ code: embedData.code, lang: embedData.lang as any }); + const target = isTranslationMode + ? `/v2/editor?code=${encoded}&targetLang=${showAst ? 'ast' : targetLang}` + : `/v2/editor?code=${encoded}`; + window.open(target, '_blank'); }; - useEffect(() => { - const handleMouseMove = (e: MouseEvent) => { - if (resizingIdx === null) return; - setSourceWidth((prev) => Math.max(150, prev + e.movementX)); - }; - - const handleMouseUp = () => setResizingIdx(null); - - if (resizingIdx !== null) { - window.addEventListener('mousemove', handleMouseMove); - window.addEventListener('mouseup', handleMouseUp); - } - return () => { - window.removeEventListener('mousemove', handleMouseMove); - window.removeEventListener('mouseup', handleMouseUp); - }; - }, [resizingIdx]); - if (!embedData) { - return ( -
-
- -
-

{error || 'No Code Found'}

-

The embed data could not be loaded.

-
-
-
- ); + return ; } - const translation = getTranslation(ast, currentTargetLang); - - // Shared action buttons used in both layouts - const actionButtons = ( -
- {!isDebugging ? ( - <> - - - - ) : ( - <> - - - - )} - -
+ const actions = ( + ); - // Shared output content used in both layouts - const outputContent = ( -
-
- {error && ( -
- Error: {error} -
- )} - {isDebugging && currentCallStack.length > 0 && ( -
-
- Variables - {currentCallStack.length > 1 && ( - - — in {currentCallStack[currentCallStack.length - 1].name}() - - )} -
- -
- )} - {output.length === 0 && !error ? ( -
- {isDebugging ? 'No output yet — keep stepping…' : 'Run code to see output...'} -
- ) : ( - output.map((line, idx) => ( -
- - {line} -
- )) - )} -
- {(waitingForInput || waitingForNormalInput) && ( -
- -
- { - if (e.key === 'Enter') { - const input = (e.target as HTMLInputElement).value; - if (waitingForNormalInput) { - handleNormalModeInputSubmit(input); - } else { - handleSubmitInput(input); - } - (e.target as HTMLInputElement).value = ''; - } - }} - className="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded text-slate-100 text-xs font-mono placeholder:text-slate-400 focus:outline-none focus:border-indigo-400 focus:ring-1 focus:ring-indigo-400" - data-testid="stdin-input" - autoFocus={waitingForInput || waitingForNormalInput} - /> - -
-
- )} -
+ const sourcePane = ( + ); - // Shared source pane used in both layouts - const sourcePane = ( -
-
-
- Source ({embedData.lang}) -
-
- { - setEmbedData((prev) => (prev ? { ...prev, code: val } : null)); - }} - onCreateEditor={handleCreateEditor} - className="h-full font-mono" - /> -
-
- {/* Source Resize Handle */} - + const outputBody = ( + ); return ( @@ -533,94 +107,27 @@ export default function EmbedPage() { <>
{sourcePane} - - {/* Translation Pane */} -
-
-
- {/* Header with language selector */} -
-
- - {showTranslationMenu && ( -
- {(['python', 'java', 'csp', 'praxis'] as SupportedLang[]).map((lang) => ( - - ))} -
- -
- )} -
-
- - {/* Action buttons */} -
- {actionButtons} -
-
- - {/* Translation Content */} -
- {showAst ? ( -
- {ast ? ( - - ) : ( -
- Valid code required... -
- )} -
- ) : ( - - )} -
-
-
+ setShowTranslationMenu(!showTranslationMenu)} + onSelectTarget={(lang) => { + setTargetLang(lang); + setShowAst(false); + setShowTranslationMenu(false); + }} + onSelectAst={() => { + setShowAst(true); + setShowTranslationMenu(false); + }} + />
- {/* Output Pane */}
- {outputContent} + {outputBody}
) : ( @@ -638,7 +145,6 @@ export default function EmbedPage() {
{sourcePane} - {/* Output Pane (right) */}
Output - {actionButtons} + {actions}
- {outputContent} + {outputBody}
)} diff --git a/src/utils/aiPanelContext.ts b/src/utils/aiPanelContext.ts new file mode 100644 index 0000000..6aaee6c --- /dev/null +++ b/src/utils/aiPanelContext.ts @@ -0,0 +1,55 @@ +/** + * Builds the code context the AI panel sends with a message: the source plus + * every open translation, so the model sees the whole workspace rather than + * just the pane the user happens to be in. + */ + +import type { Program } from '../language/ast'; +import type { LlmPanel } from '../api/llm'; +import type { SupportedLang } from '../components/LanguageSelector'; +import type { Panel } from '../components/editor/types'; +import type { SourceMap } from '../hooks/useCodeParsing'; + +type GetTranslation = ( + ast: Program | null, + lang: SupportedLang +) => { code: string; sourceMap: SourceMap }; + +export function buildAiPanelContext( + code: string, + sourceLang: SupportedLang, + ast: Program | null, + panels: Panel[], + getTranslation: GetTranslation +): LlmPanel[] { + const result: LlmPanel[] = []; + + if (code.trim()) { + if (sourceLang === 'blocks') { + // Blocks source is Blockly workspace JSON — meaningless to the LLM. + // Send the equivalent Praxis pseudocode string instead. + const readable = ast ? getTranslation(ast, 'praxis').code : ''; + if (readable.trim()) result.push({ language: 'praxis', code: readable }); + } else { + result.push({ language: sourceLang, code }); + } + } + + if (ast) { + for (const panel of panels) { + // A blocks panel shows the same program the source already covers, + // and its JSON would only waste the model's context. + if (panel.lang === 'blocks') continue; + try { + const translated = getTranslation(ast, panel.lang).code; + if (translated?.trim()) { + result.push({ language: panel.lang, code: translated }); + } + } catch { + // Skip panels that can't currently be translated. + } + } + } + + return result; +} diff --git a/src/utils/languageSwitch.ts b/src/utils/languageSwitch.ts new file mode 100644 index 0000000..a180b6a --- /dev/null +++ b/src/utils/languageSwitch.ts @@ -0,0 +1,60 @@ +/** + * Deciding what happens to the user's program when they change the source + * language: translate it if the result is usable, otherwise say so and let the + * caller ask before discarding it. + */ + +import type { Program } from '../language/ast'; +import type { SupportedLang } from '../components/LanguageSelector'; +import type { SourceMap } from '../hooks/useCodeParsing'; + +type ParseCode = (lang: SupportedLang, input: string) => Program | null; +type GetTranslation = ( + ast: Program | null, + lang: SupportedLang +) => { code: string; sourceMap: SourceMap }; + +/** What to do with the editor's contents when switching to another language. */ +export type LanguageSwitchPlan = + /** Nothing worth keeping — open an empty program in the new language. */ + | { kind: 'empty' } + /** The program survived the round trip; open this text instead. */ + | { kind: 'translated'; code: string } + /** No working translation exists — confirm before discarding the program. */ + | { kind: 'untranslatable' }; + +/** Emitters signal an unavailable translation with these comment prefixes. */ +const FAILURE_PREFIXES = ['// Translation to', '// Valid source code required']; + +export function planLanguageSwitch( + code: string, + from: SupportedLang, + to: SupportedLang, + parseCode: ParseCode, + getTranslation: GetTranslation +): LanguageSwitchPlan { + try { + const sourceForTranslation = from === 'ast' ? 'python' : from; + // An empty program — blank text, or e.g. a Blocks workspace whose JSON + // holds no blocks — has nothing to translate; switch straight away. + const parsed = code.trim() ? parseCode(sourceForTranslation, code) : null; + if (!parsed || parsed.body.length === 0) return { kind: 'empty' }; + + const translated = getTranslation(parsed, to).code; + if (!translated?.trim() || FAILURE_PREFIXES.some((p) => translated.startsWith(p))) { + return { kind: 'untranslatable' }; + } + + // A translation that the target's own parser rejects is no use either. + try { + parseCode(to, translated); + } catch { + return { kind: 'untranslatable' }; + } + + return { kind: 'translated', code: translated }; + } catch { + // The current source doesn't parse, so it can't be translated either. + return { kind: 'untranslatable' }; + } +}