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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -499,3 +499,4 @@ Si algo cambia en el proyecto, se actualiza la frase o se añade una nueva, pero
* **2026-11-08** · Fuera el selector de permisos del chat («Sí, quítalo»): *Ask / Allow all* prometía una elección que no existía, porque pi ejecuta sin preguntar. Los MCP conectados en el propio editor siguen preguntando (ADR-040, adenda). La web y los términos ya dicen que el agente actúa sin preguntar.
* **2026-11-09** · Conectar Jira ya no depende del navegador («no conecta jira», «Sí, arréglalo»): Edge no abría el editor al pulsar *Open PiCode*. Ahora el servidor sigue el intento y el editor le pregunta cómo va, como en GitHub. La página de vuelta enseña a qué cuenta se conecta y pide confirmar, para que nadie pueda llevarse el Jira de otra persona con un enlace (ADR-042, adenda 12).
* **2026-11-09** · Release 2.0.1 en experimental («saca release no? para que probar esto»): lleva la conexión con Jira sin depender del navegador y las builds locales que ya no le quitan los enlaces a la copia instalada.
* **2026-11-09** · Las novedades salen también con los arreglos, con una parte «Bugs fixed» («de la 2.0.0 a la 2.0.1 mantenlo y vas añadiendo en una parte bugs arreglados»): las páginas con demo cambian solo al pasar de línea (2.0 a 2.1, con lo que tú me digas que lleva), y la última cifra son arreglos. Además, la ventana ya no sale en blanco cuando la versión no trae páginas propias. Y al actualizar, el editor cierra el motor de memoria que corre desde su carpeta justo antes de instalar («esto no es lo suyo»): se quedaba abierto y el actualizador no podía borrar `resources`. Solo cierra el de su propia carpeta; el de otro programa no se toca.
83 changes: 83 additions & 0 deletions extensions/picode/test/update-memory-engine.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) PiCode. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

/**
* Closing the memory engine that PiCode carries before an update replaces its folder, run on its own.
*
* What matters: an engine running from the installation's own folder is stopped (whatever the case
* of the drive letter), and nothing else is: not the one another program installed, not a folder
* with a similar name, not an answer that is not a process.
*/

import assert from 'assert';
import { test } from 'node:test';
import { isInsideFolder, memoryFolderOf, readRunningEngines, stopEnginesInside } from '../../../src/vs/platform/update/electron-main/picodeMemoryEngine.ts';

const INSTALL = 'C:\\Users\\tapla\\AppData\\Local\\Programs\\PiCode Experimental-win32-x64-experimental';
const MEMORY = `${INSTALL}\\resources\\memory`;

function processes(running: { pid: number; path: string }[], failing: number[] = []) {
const killed: number[] = [];
return {
killed,
io: {
list: () => running,
kill: (pid: number) => {
if (failing.includes(pid)) {
throw new Error('already gone');
}
killed.push(pid);
}
}
};
}

test('the memory folder is the one next to the editor code', () => {
assert.strictEqual(memoryFolderOf(`${INSTALL}\\resources\\app`), MEMORY);
});

test('an engine running from the installation is stopped, whatever the case of its drive', () => {
const { io, killed } = processes([{ pid: 15460, path: MEMORY.replace('C:', 'c:') + '\\engram.exe' }]);
assert.deepStrictEqual(stopEnginesInside(MEMORY, io, true), [15460]);
assert.deepStrictEqual(killed, [15460]);
});

test('the engine another program installed is left alone', () => {
const { io, killed } = processes([
{ pid: 28732, path: 'C:\\Users\\tapla\\AppData\\Local\\engram\\bin\\engram.exe' },
{ pid: 15460, path: `${MEMORY}\\engram.exe` },
]);
assert.deepStrictEqual(stopEnginesInside(MEMORY, io, true), [15460]);
assert.deepStrictEqual(killed, [15460]);
});

test('a folder that only starts with the same name is not the installation', () => {
assert.strictEqual(isInsideFolder(`${MEMORY}-old\\engram.exe`, MEMORY, true), false);
assert.strictEqual(isInsideFolder(`${MEMORY}\\engram.exe`, MEMORY, true), true);
assert.strictEqual(isInsideFolder(`${MEMORY}\\`, MEMORY, true), false);
});

test('one that cannot be stopped does not keep the others from stopping', () => {
const { io, killed } = processes([
{ pid: 1, path: `${MEMORY}\\engram.exe` },
{ pid: 2, path: `${MEMORY}\\engram.exe` },
], [1]);
assert.deepStrictEqual(stopEnginesInside(MEMORY, io, true), [2]);
assert.deepStrictEqual(killed, [2]);
});

test('nothing running means nothing to stop', () => {
const { io, killed } = processes([]);
assert.deepStrictEqual(stopEnginesInside(MEMORY, io, true), []);
assert.deepStrictEqual(killed, []);
});

test('the system listing is read one process per line, and anything else is ignored', () => {
const output = `15460|${MEMORY}\\engram.exe\r\n\r\nnot a process\r\n|${MEMORY}\\x.exe\r\n0|${MEMORY}\\x.exe\r\nabc|${MEMORY}\\x.exe\r\n28732|C:\\other\\engram.exe\r\n`;
assert.deepStrictEqual(readRunningEngines(output), [
{ pid: 15460, path: `${MEMORY}\\engram.exe` },
{ pid: 28732, path: 'C:\\other\\engram.exe' },
]);
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "code-oss-dev",
"version": "1.144.10",
"version": "1.144.11",
"distro": "c842171bd42ca4aef20b0a186c94d99edd763842",
"author": {
"name": "PiCode"
Expand Down
4 changes: 2 additions & 2 deletions product.json
Original file line number Diff line number Diff line change
Expand Up @@ -758,7 +758,7 @@
"default": false
}
},
"version": "1.144.10",
"picodeVersion": "2.0.1-beta",
"version": "1.144.11",
"picodeVersion": "2.0.2-beta",
"hotfixPublicKey": "MCowBQYDK2VwAyEAZes8irGrgqOvvCEWRBn3rwOG6qxSNp+zxWOX3HqXyjo="
}
96 changes: 96 additions & 0 deletions src/vs/platform/update/electron-main/picodeMemoryEngine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) PiCode. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

/**
* Closing PiCode's own memory engine before an update replaces the folder it runs from.
*
* The engine travels inside the installation (`<app>/resources/memory/engram.exe`). The window that
* started it stops it when it closes, but when the editor quits to update, that window is told to
* stop and exits in a fraction of a second: an engine that outlives it keeps its files open, and the
* updater then cannot delete the folder ("Access denied"). So the main process, once every window is
* gone and right before the installer starts, closes any engine running from the folder being
* replaced. Only by where the program is: another program's engine (one installed on its own, under
* its own folder) is never touched, and neither is anything that is not the engine.
*
* Kept free of other PiCode modules so a test can load it on its own (`node --test`).
*/

import { execFileSync } from 'child_process';
import { dirname, join, resolve } from 'path';

/** A process running the engine, with the program it was started from. */
export interface IRunningEngine {
readonly pid: number;
readonly path: string;
}

export interface IMemoryEngineProcesses {
list(): readonly IRunningEngine[];
kill(pid: number): void;
}

/** Where the installation's engine lives, from the folder the editor's code is in (`<install>/resources/app`). */
export function memoryFolderOf(appRoot: string): string {
return join(dirname(appRoot), 'memory');
}

/** Whether `file` is inside `folder`. Windows paths are compared without regard to case. */
export function isInsideFolder(file: string, folder: string, ignoreCase: boolean): boolean {
const normal = (value: string) => {
const full = resolve(value).replace(/[\\/]+$/, '');
return ignoreCase ? full.toLowerCase() : full;
};
const inside = normal(folder);
const candidate = normal(file);
return candidate.startsWith(inside + '\\') || candidate.startsWith(inside + '/');
}

/** Stops every engine started from `folder` and returns their process ids. A failure to stop one never stops the others. */
export function stopEnginesInside(folder: string, processes: IMemoryEngineProcesses, ignoreCase = process.platform === 'win32'): number[] {
const stopped: number[] = [];
for (const engine of processes.list()) {
if (!Number.isInteger(engine.pid) || engine.pid <= 0 || !isInsideFolder(engine.path, folder, ignoreCase)) {
continue;
}
try {
processes.kill(engine.pid);
stopped.push(engine.pid);
} catch {
// Already gone, or not ours to stop: the updater will say so if it still cannot go on.
}
}
return stopped;
}

/** Reads the output of the system listing: one `pid|path` per line, anything else is ignored. */
export function readRunningEngines(output: string): IRunningEngine[] {
const engines: IRunningEngine[] = [];
for (const line of output.split(/\r?\n/)) {
const bar = line.indexOf('|');
const pid = Number(line.slice(0, bar));
const path = line.slice(bar + 1).trim();
if (bar > 0 && Number.isInteger(pid) && pid > 0 && path !== '') {
engines.push({ pid, path });
}
}
return engines;
}

/** The real thing, on Windows: the engines the system is running, and a way to stop one. */
export const systemMemoryEngines: IMemoryEngineProcesses = {
list: () => {
try {
const output = execFileSync('powershell.exe', [
'-NoProfile', '-NonInteractive', '-Command',
// Where the program is comes from the system, not from anything the engine says about itself.
'Get-Process -Name engram -ErrorAction SilentlyContinue | Where-Object { $_.Path } | ForEach-Object { \'{0}|{1}\' -f $_.Id, $_.Path }'
], { encoding: 'utf8', windowsHide: true, timeout: 8000 });
return readRunningEngines(output);
} catch {
return [];
}
},
kill: pid => process.kill(pid)
};
18 changes: 18 additions & 0 deletions src/vs/platform/update/electron-main/updateService.win32.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { killTree } from '../../../base/node/processes.js';
import { getWindowsRelease } from '../../../base/node/windowsVersion.js';
import { IConfigurationService } from '../../configuration/common/configuration.js';
import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js';
import { memoryFolderOf, stopEnginesInside, systemMemoryEngines } from './picodeMemoryEngine.js';
import { IFileService } from '../../files/common/files.js';
import { ILifecycleMainService, IRelaunchHandler, IRelaunchOptions, LifecycleMainPhase } from '../../lifecycle/electron-main/lifecycleMainService.js';
import { ILogService } from '../../log/common/log.js';
Expand Down Expand Up @@ -845,12 +846,29 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun
this.availableUpdate = undefined;
}

/**
* PiCode: every window is gone by now, and the installer is about to replace the folder the memory
* engine runs from. One that outlived its window would keep those files open and the update would
* stop at "Access denied", so it is closed here, by where it runs from, before anything else.
*/
private stopMemoryEngine(): void {
try {
const stopped = stopEnginesInside(memoryFolderOf(this.environmentMainService.appRoot), systemMemoryEngines);
if (stopped.length > 0) {
this.logService.info(`update#quitAndInstall(): stopped the memory engine still running from this installation (${stopped.join(', ')})`);
}
} catch (error) {
this.logService.warn('update#quitAndInstall(): could not check for a memory engine still running', error);
}
}

protected override doQuitAndInstall(): void {
if ((this.state.type !== StateType.Ready && this.state.type !== StateType.Restarting) || !this.availableUpdate) {
return;
}

this.logService.trace('update#quitAndInstall(): running raw#quitAndInstall()');
this.stopMemoryEngine();

// The portable build has no installer to run: the staged copy is swapped in
// by its script once this process quits, and the new build starts itself.
Expand Down
6 changes: 4 additions & 2 deletions src/vs/workbench/contrib/picode/browser/picodeWhatsNew.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { IHostService } from '../../../services/host/browser/host.js';
import { IWorkbenchLayoutService } from '../../../services/layout/browser/layoutService.js';
import { IPaneCompositePartService } from '../../../services/panecomposite/browser/panecomposite.js';
import { ILifecycleService, LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js';
import { composeWhatsNew, extractWhatChanged, highlightsSince, readHistory, shouldShowWhatsNew, whatsNewHistoryUrl, whatsNewSince, WhatsNewEntry } from './picodeWhatsNewData.js';
import { composeWhatsNew, sameVersionLine, extractWhatChanged, highlightsSince, readHistory, shouldShowWhatsNew, whatsNewHistoryUrl, whatsNewSince, WhatsNewEntry } from './picodeWhatsNewData.js';
import { whatsNewHighlights } from './picodeWhatsNewHighlights.js';
import { IPicodeProEntitlement } from './account/picodeProEntitlement.js';
import { IWhatsNewWindowServices, showWhatsNewWindow } from './picodeWhatsNewWindow.js';
Expand Down Expand Up @@ -66,7 +66,9 @@ async function showWhatsNew(version: string, last: string, accessor: { readonly
const subtitle = last
? localize('picodeWhatsNew.subtitleSince', "New since {0}", last)
: localize('picodeWhatsNew.subtitleAll', "Try each one right here.");
showWhatsNewWindow({ version, subtitle, highlights, changes, releaseUrl }, services);
// Inside one line of versions (2.0.0 to 2.0.1) the list is the fixes that came since: the page says so.
const fixesOnly = !manual && sameVersionLine(last, version);
showWhatsNewWindow({ version, subtitle, highlights, changes, fixesOnly, releaseUrl }, services);
return true;
}

Expand Down
11 changes: 11 additions & 0 deletions src/vs/workbench/contrib/picode/browser/picodeWhatsNewData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,17 @@ export function composeWhatsNew(entries: readonly WhatsNewEntry[]): string | und
return entries.map(entry => `## ${entry.version}\n\n${entry.notes.trim()}`).join('\n\n');
}

/**
* Whether two versions are in the same line of news (`2.0.0` and `2.0.1`): the first two numbers
* decide it. The last one is for fixes: their list comes under "Bugs fixed", and the news of the
* line (the pages with a demo) only change when the line does.
*/
export function sameVersionLine(a: string, b: string): boolean {
const line = (version: string) => /^(\d+)\.(\d+)/.exec(version)?.slice(1, 3).join('.');
const left = line(a);
return left !== undefined && left === line(b);
}

/**
* Whether the window opens after this start.
*
Expand Down
18 changes: 13 additions & 5 deletions src/vs/workbench/contrib/picode/browser/picodeWhatsNewWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export interface IWhatsNewWindowOptions {
readonly highlights: readonly IWhatsNewHighlight[];
/** The releases' list of changes, as Markdown; it may arrive after the window opened. */
readonly changes: Promise<string | undefined>;
/** The update stays inside one line of versions (2.0.0 to 2.0.1): that list is the bugs fixed since. */
readonly fixesOnly: boolean;
readonly releaseUrl: string;
}

Expand Down Expand Up @@ -94,6 +96,7 @@ export function showWhatsNewWindow(options: IWhatsNewWindowOptions, services: IW
const highlights = [...options.highlights.filter(h => h.plan === 'pro'), ...options.highlights.filter(h => h.plan === 'free')];
let changes: string | undefined;
let active = 0;
const changesTitle = () => options.fixesOnly ? localize('picodeWhatsNew.bugsFixed', "Bugs fixed") : localize('picodeWhatsNew.allChanges', "All changes");
const pageCount = () => highlights.length + (changes ? 1 : 0);

const renderNav = () => {
Expand All @@ -109,7 +112,7 @@ export function showWhatsNewWindow(options: IWhatsNewWindowOptions, services: IW
navRow(highlight.icon, highlight.title, index);
});
if (changes) {
navRow(Codicon.listUnordered, localize('picodeWhatsNew.allChanges', "All changes"), highlights.length);
navRow(Codicon.listUnordered, changesTitle(), highlights.length);
}
};
const navRow = (icon: ThemeIcon, label: string, index: number) => {
Expand All @@ -134,7 +137,7 @@ export function showWhatsNewWindow(options: IWhatsNewWindowOptions, services: IW
if (highlight) {
renderHighlight(page, highlight, pageStore, services, close, render);
} else if (changes) {
append(page, $('h2.picode-whatsnew-page-title')).textContent = localize('picodeWhatsNew.allChanges', "All changes");
append(page, $('h2.picode-whatsnew-page-title')).textContent = changesTitle();
const rendered = services.markdownRendererService.render(new MarkdownString(changes));
pageStore.add(rendered);
page.appendChild(rendered.element).classList.add('picode-whatsnew-changes');
Expand Down Expand Up @@ -186,9 +189,14 @@ export function showWhatsNewWindow(options: IWhatsNewWindowOptions, services: IW
void options.changes.then(markdown => {
if (markdown && !disposables.isDisposed) {
changes = markdown;
// One more page in the list; the one being read (and its demo) is left as it is.
renderNav();
renderFooter();
if (highlights[active]) {
// One more page in the list; the one being read (and its demo) is left as it is.
renderNav();
renderFooter();
} else {
// Nothing of our own to show: this is the only page, and it was blank until now.
render();
}
}
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

import assert from 'assert';
import { test } from 'node:test';
import { composeWhatsNew, extractWhatChanged, highlightsSince, readHistory, shouldShowWhatsNew, whatsNewHistoryUrl, whatsNewSince } from '../picodeWhatsNewData.js';
import { composeWhatsNew, extractWhatChanged, highlightsSince, readHistory, sameVersionLine, shouldShowWhatsNew, whatsNewHistoryUrl, whatsNewSince } from '../picodeWhatsNewData.js';

test('returns the grouped summary without its heading or the sections after it', () => {
const body = ['Channel text', '', '## What changed', '', '### ✨ New', '', '- A thing', '', '## What\'s Changed', '', '* raw changelog'].join('\n');
Expand Down Expand Up @@ -49,6 +49,15 @@ test('stays quiet on a first install', () => {
assert.strictEqual(shouldShowWhatsNew({ last: '', current: '', editorLast: '1.142.14', editorCurrent: '1.142.15' }), false);
});

test('versions share their news when only the last number differs', () => {
assert.strictEqual(sameVersionLine('2.0.0-experimental', '2.0.1-experimental'), true);
assert.strictEqual(sameVersionLine('2.0.1', '2.0.12'), true);
assert.strictEqual(sameVersionLine('2.0.9', '2.1.0'), false);
assert.strictEqual(sameVersionLine('1.9.9', '2.0.0'), false);
assert.strictEqual(sameVersionLine('', '2.0.0'), false);
assert.strictEqual(sameVersionLine('junk', 'junk'), false);
});

const entry = (version: string) => ({ version, notes: `- change in ${version}` });

test('after skipping versions, shows this one first and then each skipped one, newest first', () => {
Expand Down
1 change: 1 addition & 0 deletions src/vs/workbench/contrib/picode/l10n/cs.json
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,7 @@
"picodeWhatsNew.board.doing": "Probíhá",
"picodeWhatsNew.board.done": "Hotovo",
"picodeWhatsNew.board.todo": "K udělání",
"picodeWhatsNew.bugsFixed": "Opravené chyby",
"picodeWhatsNew.close": "Zavřít",
"picodeWhatsNew.command": "Zobrazit novinky",
"picodeWhatsNew.connect": "Připojit GitHub nebo Jira",
Expand Down
1 change: 1 addition & 0 deletions src/vs/workbench/contrib/picode/l10n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,7 @@
"picodeWhatsNew.board.doing": "In Arbeit",
"picodeWhatsNew.board.done": "Erledigt",
"picodeWhatsNew.board.todo": "Zu erledigen",
"picodeWhatsNew.bugsFixed": "Behobene Fehler",
"picodeWhatsNew.close": "Schließen",
"picodeWhatsNew.command": "Neuigkeiten anzeigen",
"picodeWhatsNew.connect": "GitHub oder Jira verbinden",
Expand Down
1 change: 1 addition & 0 deletions src/vs/workbench/contrib/picode/l10n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,7 @@
"picodeWhatsNew.board.doing": "En curso",
"picodeWhatsNew.board.done": "Hecho",
"picodeWhatsNew.board.todo": "Por hacer",
"picodeWhatsNew.bugsFixed": "Errores corregidos",
"picodeWhatsNew.close": "Cerrar",
"picodeWhatsNew.command": "Mostrar las novedades",
"picodeWhatsNew.connect": "Conectar GitHub o Jira",
Expand Down
1 change: 1 addition & 0 deletions src/vs/workbench/contrib/picode/l10n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,7 @@
"picodeWhatsNew.board.doing": "En cours",
"picodeWhatsNew.board.done": "Terminé",
"picodeWhatsNew.board.todo": "À faire",
"picodeWhatsNew.bugsFixed": "Bugs corrigés",
"picodeWhatsNew.close": "Fermer",
"picodeWhatsNew.command": "Afficher les nouveautés",
"picodeWhatsNew.connect": "Connecter GitHub ou Jira",
Expand Down
1 change: 1 addition & 0 deletions src/vs/workbench/contrib/picode/l10n/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,7 @@
"picodeWhatsNew.board.doing": "In corso",
"picodeWhatsNew.board.done": "Fatto",
"picodeWhatsNew.board.todo": "Da fare",
"picodeWhatsNew.bugsFixed": "Bug risolti",
"picodeWhatsNew.close": "Chiudi",
"picodeWhatsNew.command": "Mostra le novità",
"picodeWhatsNew.connect": "Connetti GitHub o Jira",
Expand Down
Loading