Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ The format follows Keep a Changelog and groups changes by release.
- Lua `string.format` with a zero `%g` precision, such as `%.0g`, now formats like standard Lua instead of failing the run with a meaningless error.
- `lingua capsule verify` and the regression-suite export name the engine mismatch for Python, Go and web Ruby recordings. A real recording with per-line results or a failed run used to report a generic incomplete baseline instead.
- Desktop HTTP, SSE and WebSocket requests honor Stop and their timeout while the host name is still resolving, instead of waiting on a DNS lookup that never answers. A WebSocket header named `__proto__` is now sent like any other header.
- Python `print` with several arguments shows strings in the console as `print` does, without repr quotes (`sum = 55`, not `'sum =' 55`). Strings inside lists and other containers keep their quotes.
- A language-changing Save As no longer leaves a recipe bound in the background when the binding was made while the file was still writing.
- Starting the guided tour opens the console only once the tour actually begins; a start interrupted by another dialog leaves the bottom panel as it was.

## [1.6.0] — 2026-10-13

Expand Down
3 changes: 2 additions & 1 deletion src/renderer/components/GuidedTour/GuidedTourRuntime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -336,11 +336,12 @@ export function GuidedTourRuntime({
if (tabs.length === 0) {
addTab(createDefaultTab('javascript'));
}
useUIStore.getState().openBottomPanel('console');

await waitForGuidedTourSelector(GUIDED_TOUR_SELECTORS.editor);

if (generation === startGenerationRef.current && !hasActiveOverlayRef.current) {
// Open the console only for a tour that actually starts.
useUIStore.getState().openBottomPanel('console');
activeStepIndexRef.current = 0;
setActiveStepIndex(0);
}
Expand Down
9 changes: 8 additions & 1 deletion src/renderer/stores/editorDocumentSave.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export function createDocumentSaveAction(set: EditorSet, get: EditorGet) {
return false;
}

let droppedLiveBinding = false;
set(state => ({
tabs: state.tabs.map(t => {
if (t.id !== id) return t;
Expand All @@ -91,6 +92,8 @@ export function createDocumentSaveAction(set: EditorSet, get: EditorGet) {
};
}
const committed = withLiveTabState(savedTab, t, tab);
droppedLiveBinding =
t.recipeBindingId !== undefined && committed.recipeBindingId === undefined;
if (t.content !== tab.content) {
return { ...committed, content: t.content, isDirty: true };
}
Expand All @@ -112,7 +115,11 @@ export function createDocumentSaveAction(set: EditorSet, get: EditorGet) {
if (savedTab.language !== previousLanguage) {
useDependencyDetectionStore.getState().evictTab(id);
}
if (tab.recipeBindingId !== undefined && savedTab.recipeBindingId === undefined) {
// Includes a binding made while a language-changing save was writing.
if (
droppedLiveBinding ||
(tab.recipeBindingId !== undefined && savedTab.recipeBindingId === undefined)
) {
useRecipeStore.getState().unbindRecipe(id);
}

Expand Down
4 changes: 4 additions & 0 deletions src/renderer/workers/python-worker-sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,10 @@ def __lingua_print(*args, sep=None, end=None, file=None, flush=False):
# own payload entry aligned with the joined text.
payloads = []
for arg in args:
# print() shows str(arg); a repr payload would add quotes to strings.
if isinstance(arg, str):
payloads.append({"kind": "primitive", "type": "string", "repr": arg})
continue
payload = __lingua_console_serialize(arg)
if payload is None:
payload = {"kind": "rawText", "text": __lingua_repr_safe(arg)}
Expand Down
3 changes: 3 additions & 0 deletions tests/components/GuidedTourFocus.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ describe('GuidedTour focus management (accessibility pass)', () => {
})
);
const { rerender } = renderTour();
uiState.openBottomPanel.mockClear();
fireEvent.click(screen.getByTestId('trigger'));
await waitFor(() => expect(wait).toHaveBeenCalled());

Expand All @@ -259,8 +260,10 @@ describe('GuidedTour focus management (accessibility pass)', () => {
await act(async () => ready());

expect(screen.queryByRole('dialog')).toBeNull();
expect(uiState.openBottomPanel).not.toHaveBeenCalled();
fireEvent.click(screen.getByTestId('trigger'));
expect(await screen.findByRole('dialog')).toBeTruthy();
expect(uiState.openBottomPanel).toHaveBeenCalledWith('console');
});
it('does not restore the old trigger over a newer overlay focus target', async () => {
const { rerender } = renderTour();
Expand Down
6 changes: 5 additions & 1 deletion tests/e2e/tourVisual.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,11 @@ test.describe('website tour evidence', () => {
await replaceEditorText(page, PYTHON_SOURCE);
await expect(page.getByText('slowest path: db').first()).toBeVisible({ timeout: 60_000 });
await openConsole(page);
await expect(page.getByText(/slowest path: db'/).first()).toBeVisible();
// The console row prints the str() text, so scope it to the console
// rather than relying on repr quotes to tell it from the inline pill.
await expect(
page.getByTestId('console-entry-row').filter({ hasText: 'slowest path: db' }).first()
).toBeVisible();
await capture(page, `workspace-python-${language}.png`);

// Command palette filtered to sql.
Expand Down
28 changes: 28 additions & 0 deletions tests/stores/editorSaveInputRetention.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import * as editorPersistence from '@/stores/editorPersistence';
import { createDefaultTab, useEditorStore } from '@/stores/editorStore';
import { useRecipeStore } from '@/stores/recipeStore';
import { useSettingsStore } from '@/stores/settingsStore';

const bridge = Object.getOwnPropertyDescriptor(window, 'lingua');
Expand Down Expand Up @@ -107,3 +109,29 @@ it('drops a recipe binding from the tab when the save drops it', async () => {
const saved = useEditorStore.getState().tabs.find(t => t.id === tab.id)!;
expect(saved.recipeBindingId).toBeUndefined();
});

it('unbinds a recipe bound while a language-changing Save As was writing', async () => {
const tab = { ...createDefaultTab('javascript'), name: 'demo.js' };
useEditorStore.getState().addTab(tab);
vi.spyOn(editorPersistence, 'persistTab').mockImplementation(async original => {
// The user binds a recipe while the write is in flight.
useEditorStore.setState(state => ({
tabs: state.tabs.map(t => (t.id === tab.id ? { ...t, recipeBindingId: 'recipe-1' } : t)),
}));
useRecipeStore.getState().bindRecipeToTab(tab.id, 'recipe-1');
return {
...original,
name: 'demo.py',
language: 'python',
filePath: '/tmp/demo.py',
rootId: 'root-demo',
relativePath: 'demo.py',
};
});
const unbind = vi.spyOn(useRecipeStore.getState(), 'unbindRecipe');
expect(await useEditorStore.getState().saveTabById(tab.id, true)).toBe(true);
const saved = useEditorStore.getState().tabs.find(t => t.id === tab.id)!;
expect(saved.language).toBe('python');
expect(saved.recipeBindingId).toBeUndefined();
expect(unbind).toHaveBeenCalledWith(tab.id);
});
43 changes: 43 additions & 0 deletions tests/workers/pythonPrintPayloads.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { spawnSync } from 'node:child_process';
import { describe, expect, it } from 'vitest';
import { buildPythonExecutionBootstrapSource } from '../../src/renderer/workers/python-worker-sources';

const hasPython = spawnSync('python3', ['--version']).status === 0;

/** Run the real bootstrap under CPython and return the captured print entries. */
function printEntries(userCode: string): Array<{ text: string; payloads: unknown[] }> {
const bootstrap = buildPythonExecutionBootstrapSource({
richConsoleEnabled: true,
sourceMappingEnabled: false,
});
const dump = '\nimport json\nsys.__stdout__.write(json.dumps(__lingua_print_entries))\n';
const result = spawnSync('python3', ['-c', `${bootstrap}\n${userCode}${dump}`], {
encoding: 'utf8',
});
if (result.status !== 0) throw new Error(result.stderr);
return JSON.parse(result.stdout);
}

describe.skipIf(!hasPython)('Python print payloads', () => {
it('shows top-level string arguments as print does, without repr quotes', () => {
const [entry] = printEntries('print("sum =", 55)\n');
expect(entry?.text).toBe('sum = 55\n');
expect(entry?.payloads).toEqual([
{ kind: 'primitive', type: 'string', repr: 'sum =' },
{ kind: 'primitive', type: 'number', repr: '55' },
]);
});

it('keeps repr for strings nested in containers, matching print', () => {
const [entry] = printEntries('print(["a"], "b")\n');
expect(entry?.text).toBe("['a'] b\n");
expect(entry?.payloads).toEqual([
{
kind: 'array',
length: 1,
entries: [{ index: 0, value: { kind: 'primitive', type: 'string', repr: "'a'" } }],
},
{ kind: 'primitive', type: 'string', repr: 'b' },
]);
});
});