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
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,11 @@ export interface IPositronPackagesService {
/**
* Gets the installed packages for the active session.
* @param token Optional cancellation token
* @param forceMetadata When `true`, recompute outdated metadata live for
* every package instead of reusing still-fresh cached state. Set for
* user-initiated refreshes so an explicit Refresh is always authoritative.
*/
refreshPackages(token?: CancellationToken): Promise<ILanguageRuntimePackage[]>;

/**
* Force refresh package metadata, clearing the cache.
* @param token Optional cancellation token
*/
refreshMetadata(token?: CancellationToken): Promise<void>;
refreshPackages(token?: CancellationToken, forceMetadata?: boolean): Promise<ILanguageRuntimePackage[]>;

/**
* Install packages in the active session.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,6 @@ export const PACKAGES_UPDATE_COMMAND_ID = 'positronPackages.updatePackage';
export const PACKAGES_UPDATE_ALL_COMMAND_ID = 'positronPackages.updateAllPackages';
export const PACKAGES_UNINSTALL_COMMAND_ID = 'positronPackages.uninstallPackage';
export const PACKAGES_REFRESH_COMMAND_ID = 'positronPackages.refreshPackages';
export const PACKAGES_REFRESH_METADATA_COMMAND_ID = 'positronPackages.refreshMetadata';

const PACKAGES_CATEGORY = nls.localize2('packages', 'Packages');

Expand Down Expand Up @@ -268,7 +267,9 @@ class RefreshPackagesAction extends Action2 {
delay: 500
}, async () => {
try {
return await service.refreshPackages(cts.token);
// User-initiated refresh: force a live outdated recompute so
// the pane can't keep showing stale cached indicators.
return await service.refreshPackages(cts.token, true /* forceMetadata */);
} catch (error) {
notifications.error(cleanErrorMessage(error));
throw error;
Expand Down Expand Up @@ -626,49 +627,6 @@ class UninstallSelectedPackageAction extends Action2 {
}
}

class RefreshMetadataAction extends Action2 {
constructor() {
super({
id: PACKAGES_REFRESH_METADATA_COMMAND_ID,
title: nls.localize2('refreshMetadata', 'Refresh Metadata'),
category: PACKAGES_CATEGORY,
f1: true,
precondition: ContextKeyExpr.and(POSITRON_PACKAGES_ENABLED, PACKAGES_CAN_RUN_ACTION),
menu: {
id: MenuId.ViewTitle,
when: PACKAGES_VIEW_VISIBLE,
group: 'packages_metadata',
order: 1
}
});
}
override async run(accessor: ServicesAccessor): Promise<void> {
const service = accessor.get<IPositronPackagesService>(IPositronPackagesService);
const notifications = accessor.get<INotificationService>(INotificationService);
const progress = accessor.get<IProgressService>(IProgressService);

const cts = new CancellationTokenSource();

try {
await progress.withProgress({
title: nls.localize('positronPackages.refreshingMetadata', 'Refreshing Package Metadata...'),
location: ProgressLocation.Notification,
cancellable: true,
delay: 500
}, async () => {
try {
await service.refreshMetadata(cts.token);
} catch (error) {
notifications.error(cleanErrorMessage(error));
throw error;
}
}, () => cts.cancel());
} finally {
cts.dispose(true);
}
}
}

/**
* Switches the Packages view to the expanded card layout.
* Only visible in the view title when the view is currently showing compact rows.
Expand Down Expand Up @@ -772,7 +730,6 @@ CommandsRegistry.registerCommand(PACKAGES_OPEN_COMMAND_ID,

registerAction2(InstallPackageAction);
registerAction2(RefreshPackagesAction);
registerAction2(RefreshMetadataAction);
registerAction2(UninstallPackageAction);
registerAction2(UpdatePackageAction);
registerAction2(UpdateAllPackagesAction);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@ export interface IPositronPackagesInstance {
session: ILanguageRuntimeSession;
attachRuntime(): void;
detachRuntime(): void;
refreshPackages(token?: CancellationToken): Promise<ILanguageRuntimePackage[]>;
refreshMetadata(token?: CancellationToken): Promise<void>;
refreshPackages(token?: CancellationToken, forceMetadata?: boolean): Promise<ILanguageRuntimePackage[]>;
installPackages(packages: IPackageSpec[], token?: CancellationToken): Promise<void>;
uninstallPackages(packageNames: string[], token?: CancellationToken): Promise<void>;
updatePackages(packages: IPackageSpec[], token?: CancellationToken): Promise<void>;
Expand Down Expand Up @@ -182,39 +181,20 @@ export class PositronPackagesInstance extends Disposable implements IPositronPac
return packageManager;
}

async refreshPackages(token?: CancellationToken): Promise<ILanguageRuntimePackage[]> {
async refreshPackages(token?: CancellationToken, forceMetadata: boolean = false): Promise<ILanguageRuntimePackage[]> {
const packageManager = this.getPackageManagerOrThrow();
const effectiveToken = token ?? CancellationToken.None;

// Loading
this._onDidChangeRefreshState.fire(true);
try {
await this._refreshPackagesInternal(packageManager, effectiveToken);
await this._refreshPackagesInternal(packageManager, effectiveToken, forceMetadata);
return this.packages;
} finally {
this._onDidChangeRefreshState.fire(false);
}
}

/**
* Force refresh metadata for all packages, clearing the cache first.
*/
async refreshMetadata(token?: CancellationToken): Promise<void> {
const packageManager = this.getPackageManagerOrThrow();
const effectiveToken = token ?? CancellationToken.None;

if (!packageManager.getPackageMetadata || this._packages.length === 0) {
return;
}

// Cancel any in-flight fetch before clearing the cache so a stale
// fetch from refreshPackages can't repopulate it after the clear.
this._metadataFetch?.cancel();
this._metadataCache.clear();

await this._fetchAndMergeMetadata(packageManager, effectiveToken, true /* fetchAll */);
}

/**
* Internal helper to refresh packages with two-stage metadata fetch.
* Stage 1: Get basic packages and fire event immediately (with cached metadata).
Expand All @@ -223,20 +203,23 @@ export class PositronPackagesInstance extends Disposable implements IPositronPac
private async _refreshPackagesInternal(
packageManager: ReturnType<typeof this.getPackageManagerOrThrow>,
token: CancellationToken,
forceMetadata: boolean = false,
): Promise<void> {
// Stage 1: Get basic package list and fire event (getter merges cached metadata)
this._packages = await packageManager.getPackages(token);
this._onDidRefreshPackagesInstance.fire(this.packages);

// Stage 2: Fetch metadata asynchronously (don't block). When the
// persisted entry has aged past its freshness window, refetch every
// package so a new upstream release surfaces even though nothing
// installed locally changed; otherwise only the packages without a
// fresh cache hit are fetched (and a fully-fresh warm start makes no
// network call at all). Use CancellationToken.None since this runs
// after the main operation completes.
// Stage 2: Fetch metadata asynchronously (don't block). Refetch every
// package when `forceMetadata` is set (a user-initiated refresh, which
// must be authoritative even inside the freshness window) or when the
// persisted entry has aged past its freshness window (so a new upstream
// release surfaces even though nothing installed locally changed);
// otherwise only the packages without a fresh cache hit are fetched
// (and a fully-fresh warm start makes no network call at all). Use
// CancellationToken.None since this runs after the main operation
// completes.
if (packageManager.getPackageMetadata && this._packages.length > 0) {
const fetchAll = !this._cache.isFresh(this._runtimeId);
const fetchAll = forceMetadata || !this._cache.isFresh(this._runtimeId);
this._fetchAndMergeMetadata(packageManager, CancellationToken.None, fetchAll);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,27 +218,18 @@ export class PositronPackagesService extends Disposable implements IPositronPack
this._onDidChangeItemSize.fire(itemSize);
}

async refreshPackages(token?: CancellationToken): Promise<ILanguageRuntimePackage[]> {
async refreshPackages(token?: CancellationToken, forceMetadata?: boolean): Promise<ILanguageRuntimePackage[]> {
const instance = this._activeInstance;
if (instance) {
return await Promise.race([
instance.refreshPackages(token),
instance.refreshPackages(token, forceMetadata),
timeout(TIMEOUT_REFRESH_MS).then(() => { throw new Error('Package refresh timed out'); })
]);
}

throw new Error('No active session found.');
}

async refreshMetadata(token?: CancellationToken): Promise<void> {
const instance = this._activeInstance;
if (instance) {
return await instance.refreshMetadata(token);
}

throw new Error('No active session found.');
}

async installPackages(packages: IPackageSpec[], token?: CancellationToken): Promise<void> {
const instance = this._activeInstance;
if (instance) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,31 @@ describe('PositronPackagesInstance disk-cache integration', () => {
expect(getPackageMetadata).not.toHaveBeenCalled();
});

it('forces a live refetch on a fresh, fully-covered entry when forceMetadata is set', async () => {
// Mirror of the "makes no network call" test above: same fresh, fully-
// covered entry, but forceMetadata flips it from re-rendering cache to a
// live refetch. The cache flags numpy as outdated; the repository has
// since caught up, so the live fetch reports it current.
seed({
numpy: { version: '1.26.0', outdated: true, latestVersion: '2.0.0' },
pandas: { version: '2.0.0', outdated: false },
}, 1 * HOUR_MS);
getPackageMetadata.mockResolvedValue(new Map<string, Partial<ILanguageRuntimePackage>>([
['numpy', { outdated: false }],
['pandas', { outdated: false }],
]));

const instance = makeInstance();
const fires = waitForEvents(instance.onDidRefreshPackagesInstance, 2);
await instance.refreshPackages(CancellationToken.None, true /* forceMetadata */);
const [, stage2] = await fires;

// The forced Stage 2 refetches every package (not just uncached ones, as
// a non-forced refresh of a fresh entry would) and clears the stale flag.
expect(getPackageMetadata).toHaveBeenCalledWith(['numpy', 'pandas'], expect.anything());
expect(stage2.find(p => p.name === 'numpy')?.outdated).toBe(false);
});

it('renders a stale entry then refetches every package', async () => {
seed({ numpy: { version: '1.26.0', outdated: true, latestVersion: '2.0.0' } }, 25 * HOUR_MS);

Expand Down
Loading