feat: copy output easily. also build fixes.

This commit is contained in:
2026-02-23 12:07:19 +01:00
parent caa3f3c061
commit bf945716f9
12 changed files with 135 additions and 7 deletions

View File

@@ -2,6 +2,7 @@
"chat.tools.terminal.autoApprove": {
"npx vitest": true,
"npx tsc": true,
"git remote": true
"git remote": true,
"npx asar": true
}
}

View File

@@ -92,6 +92,31 @@
gap: 6px;
}
.output-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.output-toolbar {
display: flex;
justify-content: flex-end;
}
.output-copy-button {
font-size: 11px;
padding: 3px 10px;
background-color: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
cursor: pointer;
}
.output-copy-button:hover {
background-color: var(--vscode-list-hoverBackground);
}
.output-item {
background-color: var(--vscode-sideBar-background);
border-radius: 4px;

View File

@@ -250,6 +250,35 @@ export const Panel: React.FC = () => {
});
};
const handleCopyOutput = async () => {
if (panelOutputEntries.length === 0) {
return;
}
const outputText = panelOutputEntries.map((entry) => entry.message).join('\n\n');
if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
await navigator.clipboard.writeText(outputText);
return;
}
if (typeof document === 'undefined' || typeof document.createElement !== 'function') {
return;
}
const textArea = document.createElement('textarea');
textArea.value = outputText;
textArea.setAttribute('readonly', '');
textArea.style.position = 'absolute';
textArea.style.left = '-9999px';
document.body.appendChild(textArea);
textArea.select();
if (typeof document.execCommand === 'function') {
document.execCommand('copy');
}
document.body.removeChild(textArea);
};
const renderTaskRow = (task: TaskProgress, isChild = false) => (
<div key={task.taskId} className={`task-item status-${task.status} ${isChild ? 'task-child-row' : ''}`.trim()}>
<div className="task-status">
@@ -387,12 +416,25 @@ export const Panel: React.FC = () => {
panelOutputEntries.length === 0 ? (
<div className="panel-empty">{t('panel.noOutput')}</div>
) : (
<div className="output-list">
{panelOutputEntries.map((entry) => (
<div key={entry.id} className={`output-item output-${entry.kind}`}>
{entry.message}
</div>
))}
<div className="output-content">
<div className="output-toolbar">
<button
type="button"
className="output-copy-button"
onClick={() => {
void handleCopyOutput();
}}
>
{t('panel.copyOutput')}
</button>
</div>
<div className="output-list">
{panelOutputEntries.map((entry) => (
<div key={entry.id} className={`output-item output-${entry.kind}`}>
{entry.message}
</div>
))}
</div>
</div>
)
)}

View File

@@ -629,6 +629,7 @@
"panel.closeTitle": "Panel schließen",
"panel.noRecentTasks": "Keine aktuellen Aufgaben",
"panel.noOutput": "Keine Ausgabe",
"panel.copyOutput": "Ausgabe kopieren",
"panel.openPostEditor": "Öffne einen Beitragseditor, um Beitragslinks zu sehen",
"panel.loadingPostLinks": "Beitragslinks werden geladen...",
"panel.noPostLinks": "Keine Beitragslinks für diesen Beitrag",

View File

@@ -629,6 +629,7 @@
"panel.closeTitle": "Close panel",
"panel.noRecentTasks": "No recent tasks",
"panel.noOutput": "No output",
"panel.copyOutput": "Copy Output",
"panel.openPostEditor": "Open a post editor to view post links",
"panel.loadingPostLinks": "Loading post links...",
"panel.noPostLinks": "No post links for this post",

View File

@@ -629,6 +629,7 @@
"panel.closeTitle": "Cerrar panel",
"panel.noRecentTasks": "No hay tareas recientes",
"panel.noOutput": "Sin salida",
"panel.copyOutput": "Copiar salida",
"panel.openPostEditor": "Abre un editor de entradas para ver los enlaces",
"panel.loadingPostLinks": "Cargando enlaces de entradas...",
"panel.noPostLinks": "No hay enlaces para esta entrada",

View File

@@ -629,6 +629,7 @@
"panel.closeTitle": "Fermer le panneau",
"panel.noRecentTasks": "Aucune tâche récente",
"panel.noOutput": "Aucune sortie",
"panel.copyOutput": "Copier la sortie",
"panel.openPostEditor": "Ouvrez un éditeur d'article pour voir les liens",
"panel.loadingPostLinks": "Chargement des liens d'articles...",
"panel.noPostLinks": "Aucun lien pour cet article",

View File

@@ -629,6 +629,7 @@
"panel.closeTitle": "Chiudi pannello",
"panel.noRecentTasks": "Nessuna attività recente",
"panel.noOutput": "Nessun output",
"panel.copyOutput": "Copia output",
"panel.openPostEditor": "Apri un editor post per visualizzare i collegamenti",
"panel.loadingPostLinks": "Caricamento collegamenti post...",
"panel.noPostLinks": "Nessun collegamento per questo post",

View File

@@ -0,0 +1,3 @@
export function resolvePyodideIndexURL(workerModuleUrl: string): string {
return new URL('../../../node_modules/pyodide/', workerModuleUrl).toString();
}

View File

@@ -1,6 +1,7 @@
import { loadPyodide, type PyodideInterface } from 'pyodide';
import type { PythonWorkerMessage, PythonWorkerRequest } from './runtimeProtocol';
import { parseMacroContextV1, parseMacroResultV1 } from './abiV1';
import { resolvePyodideIndexURL } from './pyodideAssetUrl';
let runtime: PyodideInterface | null = null;
let activeRequestId: string | null = null;
@@ -183,6 +184,7 @@ json.dumps(__bds_entrypoints)
async function bootstrapRuntime(): Promise<void> {
try {
runtime = await loadPyodide({
indexURL: resolvePyodideIndexURL(import.meta.url),
stdout: (chunk) => {
if (!activeRequestId) {
return;

View File

@@ -253,6 +253,40 @@ describe('Panel', () => {
expect(screen.getByText('hello from script')).toBeInTheDocument();
});
it('copies output entries from output tab with copy button', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(globalThis, 'navigator', {
value: { clipboard: { writeText } },
configurable: true,
});
useAppStore.setState({
panelActiveTab: 'output',
panelOutputEntries: [
{
id: 'output-1',
message: 'pyodide.asm.js missing',
createdAt: '2026-02-22T00:00:00.000Z',
kind: 'error',
},
{
id: 'output-2',
message: 'second line',
createdAt: '2026-02-22T00:00:01.000Z',
kind: 'stdout',
},
],
});
render(<Panel />);
fireEvent.click(screen.getByRole('button', { name: 'Copy Output' }));
await vi.waitFor(() => {
expect(writeText).toHaveBeenCalledWith('pyodide.asm.js missing\n\nsecond line');
});
});
it('renders grouped tasks as expandable parent rows with child task names in tasks tab', async () => {
useAppStore.setState({
tasks: [

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { resolvePyodideIndexURL } from '../../../src/renderer/python/pyodideAssetUrl';
describe('resolvePyodideIndexURL', () => {
it('resolves to packaged node_modules path for dist worker urls', () => {
const workerUrl = 'file:///Applications/bDS.app/Contents/Resources/app.asar/dist/renderer/assets/pythonRuntime.worker-abc123.js';
expect(resolvePyodideIndexURL(workerUrl)).toBe(
'file:///Applications/bDS.app/Contents/Resources/app.asar/node_modules/pyodide/'
);
});
it('resolves to vite node_modules path for dev worker urls', () => {
const workerUrl = 'http://localhost:5173/src/renderer/python/pythonRuntime.worker.ts';
expect(resolvePyodideIndexURL(workerUrl)).toBe('http://localhost:5173/node_modules/pyodide/');
});
});