microsoft/vscode · #333404
editor: show session homes in breadcrumbs
src/vs/editor/standalone/browser/standaloneServices.ts4 + / 0 −
@@ -982,6 +982,10 @@ class StandaloneUriLabelService implements ILabelService { throw new Error('Not implemented'); } + public getUriHome(): undefined {+ return undefined;+ }+ public registerCachedFormatter(formatter: ResourceLabelFormatter): IDisposable { return this.registerFormatter(formatter); }src/vs/platform/agentHost/common/workspacelessScratchDir.tsadded11 + / 0 −
@@ -0,0 +1,11 @@+/*---------------------------------------------------------------------------------------------+ * Copyright (c) Microsoft Corporation. All rights reserved.+ * Licensed under the MIT License. See License.txt in the project root for license information.+ *--------------------------------------------------------------------------------------------*/++import { joinPath } from '../../../base/common/resources.js';+import { URI } from '../../../base/common/uri.js';++export function workspacelessScratchDir(userHome: URI, sessionId: string): URI {+ return joinPath(userHome, '.copilot', 'chats', sessionId);+}src/vs/platform/agentHost/node/copilot/copilotAgent.ts1 + / 1 −
@@ -31,7 +31,7 @@ import { IInstantiationService } from '../../../instantiation/common/instantiati import { ILogService, LogLevel } from '../../../log/common/log.js'; import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { INativeEnvironmentService } from '../../../../platform/environment/common/environment.js';-import { workspacelessScratchDir } from '../workspacelessScratchDir.js';+import { workspacelessScratchDir } from '../../common/workspacelessScratchDir.js'; import { IAgentHostCheckpointService } from '../../common/agentHostCheckpointService.js'; import type { IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; import { IAgentHostReviewService } from '../../common/agentHostReviewService.js';src/vs/platform/agentHost/node/workspacelessScratchDir.ts1 + / 5 −
@@ -4,19 +4,15 @@ *--------------------------------------------------------------------------------------------*/ import * as fs from 'fs/promises';-import { joinPath } from '../../../base/common/resources.js'; import { URI } from '../../../base/common/uri.js';+import { workspacelessScratchDir } from '../common/workspacelessScratchDir.js'; /** * Stable, deterministic per-session scratch directory for a workspace-less * (workspace-less chat) session: `<userHome>/.copilot/chats/<sessionId>`. Shared by the * Copilot and Claude agents so both resolve the same cwd for a session that was * created with no `workingDirectory`. */-export function workspacelessScratchDir(userHome: URI, sessionId: string): URI {- return joinPath(userHome, '.copilot', 'chats', sessionId);-}- /** Ensures the workspace-less scratch dir exists (mkdir -p), returning it. */ export async function ensureWorkspacelessScratchDir(userHome: URI, sessionId: string): Promise<URI> { const dir = workspacelessScratchDir(userHome, sessionId);src/vs/platform/label/common/label.ts4 + / 0 −
@@ -28,6 +28,8 @@ export interface ILabelService { getHostLabel(scheme: string, authority?: string): string; getHostTooltip(scheme: string, authority?: string): string | undefined; getSeparator(scheme: string, authority?: string): '/' | '\\';+ /** Returns the display home containing `resource`. */+ getUriHome(resource: URI): URI | undefined; registerFormatter(formatter: ResourceLabelFormatter): IDisposable; readonly onDidChangeFormatters: Event<IFormatterChangeEvent>;@@ -53,6 +55,8 @@ export interface IFormatterChangeEvent { export interface ResourceLabelFormatter { scheme: string; authority?: string;+ /** URI path used as a display home. Runtime registrations only. */+ home?: string; priority?: boolean; formatting: ResourceLabelFormatting; }src/vs/sessions/LAYOUT.md2 + / 0 −
@@ -76,6 +76,8 @@ The durable state and transition catalog lives in [SINGLE_PANE_SCENARIOS.md](SIN Editors must be opened through `IEditorService`. Sessions-specific presentation must not bypass editor service behavior by opening directly on an editor group. +Session providers register internal per-session directories as resource label homes. URI labels render as `<home label>/<relative path>`, and breadcrumbs render the same home label as their root segment. Without a matching home formatter, existing URI-label and breadcrumb behavior is unchanged.+ ## Custom views `ICustomViewService` owns the active contributed full-surface view.src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts39 + / 1 −
@@ -2536,8 +2536,9 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement protected readonly _onDidChangeSessions = this._register(new Emitter<ISessionChangeEvent>()); private readonly _onDidChangeSessionsFromNotifications = this._register(new Emitter<ISessionChangeEvent>());- private readonly _onDidChangeSessionsImmediately = Event.any(this._onDidChangeSessions.event, this._onDidChangeSessionsFromNotifications.event);+ protected readonly _onDidChangeSessionsImmediately = Event.any(this._onDidChangeSessions.event, this._onDidChangeSessionsFromNotifications.event); readonly onDidChangeSessions = debounceSessionChangeEvents(this._onDidChangeSessionsFromNotifications.event, this._onDidChangeSessions.event, this._store);+ protected readonly _onDidChangeDraftSessions = this._register(new Emitter<void>()); protected readonly _onDidReplaceSession = this._register(new Emitter<{ readonly from: ISession; readonly to: ISession }>()); readonly onDidReplaceSession: Event<{ readonly from: ISession; readonly to: ISession }> = this._onDidReplaceSession.event;@@ -2668,11 +2669,13 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement */ protected _disposeAllNewSessions(): void { this._newSessions.clearAndDisposeAll();+ this._onDidChangeDraftSessions.fire(); } deleteNewSession(sessionId: string): void { if (this._newSessions.has(sessionId)) { this._newSessions.deleteAndDispose(sessionId);+ this._onDidChangeDraftSessions.fire(); } } @@ -3135,6 +3138,37 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return sessions; } + getResourceLabelHomes(): { readonly uri: URI; readonly label: string }[] {+ const homes: { readonly uri: URI; readonly label: string }[] = [];+ for (const session of this.getKnownSessions()) {+ if (session.isQuickChat?.get()) {+ const adapter = session instanceof AgentHostSessionAdapter ? session : undefined;+ const label = this.getResourceLabelHomeLabel(session);+ homes.push(...(adapter?.workingDirectories ?? []).map(uri => ({ uri, label })));+ }+ }+ return homes;+ }++ protected getResourceLabelHomeLabel(session: ISession): string {+ const providerLabel = this.sessionTypes.find(type => type.id === session.sessionType)?.label ?? session.sessionType;+ return `${providerLabel}/${localize('sessionHome', "Session")}`;+ }++ protected getKnownSessions(): ISession[] {+ const sessions = new Map<string, ISession>();+ for (const session of this._sessionCache.values()) {+ sessions.set(session.resource.toString(), session);+ }+ for (const newSession of this._newSessions.values()) {+ sessions.set(newSession.session.resource.toString(), newSession.session);+ }+ if (this._pendingSession) {+ sessions.set(this._pendingSession.resource.toString(), this._pendingSession);+ }+ return [...sessions.values()];+ }+ getSessionByResource(resource: URI): ISession | undefined { for (const newSession of this._newSessions.values()) { if (newSession.session.resource.toString() === resource.toString()) {@@ -3258,6 +3292,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement throw err; } this._newSessions.set(newSession.sessionId, newSession);+ this._onDidChangeDraftSessions.fire(); newSession.observeClientCustomAgents(activeClientScope.customAgents, () => { this._onDidChangeCustomAgents.fire(); this._onDidChangeCustomizations.fire();@@ -3787,6 +3822,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement clearSessionConfig(sessionId: string): void { if (this._newSessions.has(sessionId)) { this._newSessions.deleteAndDispose(sessionId);+ this._onDidChangeDraftSessions.fire(); } } @@ -4723,6 +4759,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement newSession.graduate(); if (this._newSessions.get(newSession.sessionId) === newSession) { this._newSessions.deleteAndDispose(newSession.sessionId);+ this._onDidChangeDraftSessions.fire(); } // Clear the pending session before firing the replace event so // that any synchronous listener calling getSessions() sees only@@ -4753,6 +4790,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement newSession.graduate(); if (this._newSessions.get(newSession.sessionId) === newSession) { this._newSessions.deleteAndDispose(newSession.sessionId);+ this._onDidChangeDraftSessions.fire(); } this._onDidChangeSessions.fire({ added: [], removed: [skeleton], changed: [] }); throw new Error(localize('sessionNotCommitted', "Agent host session was not committed."));src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts38 + / 3 −
@@ -12,13 +12,14 @@ import { DisposableStore, IDisposable } from '../../../../../base/common/lifecyc import { ResourceSet } from '../../../../../base/common/map.js'; import { Schemas } from '../../../../../base/common/network.js'; import { autorun, constObservable, IObservable } from '../../../../../base/common/observable.js';-import { basename, dirname, isEqualOrParent, relativePath } from '../../../../../base/common/resources.js';+import { basename, dirname, isEqualOrParent, joinPath, relativePath } from '../../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { type AgentHostUriMapper, LOCAL_AGENT_HOST_AUTHORITY, toAgentHostContentUri, toAgentHostUri } from '../../../../../platform/agentHost/common/agentHostUri.js';-import { type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agent.js';+import { AgentSession, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agent.js'; import { affectsAgentHostProviderPreference, IAgentConnection, IAgentHostService, shouldSurfaceLocalAgentHostProvider } from '../../../../../platform/agentHost/common/agentService.js';+import { workspacelessScratchDir } from '../../../../../platform/agentHost/common/workspacelessScratchDir.js'; import type { AgentCustomization, ISessionGitState } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';@@ -34,13 +35,15 @@ import { IPreparedNewSession, ISessionsProviderAutomations, type ISessionsProvid import { WorkspaceNotTrustedError } from '../../../../services/sessions/common/sessionsManagement.js'; import { IAgentHostActiveClientService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js'; import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js';-import { getCopilotCliSessionRawId, migratedCopilotCliResource } from '../../../../../workbench/contrib/chat/browser/copilotCliEventsUri.js';+import { buildLocalSessionStateUri, getCopilotCliSessionRawId, migratedCopilotCliResource } from '../../../../../workbench/contrib/chat/browser/copilotCliEventsUri.js'; import { adoptLegacyCopilotCliResource, isLegacyMigrationEnabledAtStartup, LEGACY_MIGRATION_RESTORE_TIMEOUT_MS, LEGACY_MIGRATION_TIMEOUT_MS } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLegacyMigration.js'; import { IChatService } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ILanguageModelsService, type ILanguageModelChatMetadata } from '../../../../../workbench/contrib/chat/common/languageModels.js'; import { IWorkbenchEnvironmentService } from '../../../../../workbench/services/environment/common/environmentService.js'; import { isAgentHostProvider, LOCAL_AGENT_HOST_PROVIDER_ID, type IAgentHostSessionsProvider } from '../../../../common/agentHostSessionsProvider.js';+import { IPathService } from '../../../../../workbench/services/path/common/pathService.js';+import { ResourceLabelHomeStore } from '../../../../../workbench/services/label/common/resourceLabelHomeStore.js'; import { buildAgentHostSessionWorkspace, readBranchProtectionPatterns } from '../../../../common/agentHostSessionWorkspace.js'; import { IDevContainerAgentHostService } from '../../../../common/devContainerAgentHostService.js'; import { ChatModelSource, IGitHubInfo, ISession, ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_LOCAL } from '../../../../services/sessions/common/session.js';@@ -118,6 +121,7 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide private _automationSessionResources = new ResourceSet(); private readonly _devContainerAvailableDrafts = new Set<string>(); private readonly _devContainerDrafts = new Set<string>();+ private readonly _resourceLabelHomes: ResourceLabelHomeStore; override get order(): number { return -1;@@ -172,8 +176,10 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide @IWorkspaceTrustRequestService private readonly _workspaceTrustRequestService: IWorkspaceTrustRequestService, @IDevContainerAgentHostService private readonly _devContainerAgentHostService: IDevContainerAgentHostService, @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService,+ @IPathService pathService: IPathService, ) { super(chatSessionsService, chatService, chatWidgetService, languageModelsService, _configurationService, logService, gitHubService, instantiationService, sessionsService, activeClientService, storageService, dialogService, workspaceTrustManagementService);+ this._resourceLabelHomes = this._register(instantiationService.createInstance(ResourceLabelHomeStore)); const legacyAutomations = this._register(instantiationService.createInstance(AutomationStore, providerAutomationStorageKey(this.id))); const automations = this._register(instantiationService.createInstance(ReconnectableAgentHostAutomationStore, this.id, legacyAutomations, { toHost: resource => resource,@@ -194,6 +200,35 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide // started and the first `listSessions()` round-trip (gated on // authentication settling below) reconciles them. this._enableSessionCachePersistence(LOCAL_AGENT_HOST_CACHED_SESSIONS_STORAGE_KEY, LOCAL_AGENT_HOST_CACHED_SESSIONS_STORAGE_KEY_LEGACY);++ const updateResourceLabelHomes = () => {+ const homes = this.getResourceLabelHomes();+ const userHome = pathService.userHome({ preferLocal: true });+ const sessionStateRoot = buildLocalSessionStateUri(userHome);+ for (const session of this.getKnownSessions()) {+ const rawId = AgentSession.id(session.resource);+ const label = this.getResourceLabelHomeLabel(session);+ if (session.isQuickChat?.get() && (session.sessionType === 'copilotcli' || session.sessionType === 'claude')) {+ homes.push({ uri: workspacelessScratchDir(userHome, rawId), label });+ }+ if (session.sessionType === 'copilotcli') {+ homes.push({ uri: joinPath(sessionStateRoot, rawId), label });+ for (const artifact of session.artifacts?.get() ?? []) {+ if (!artifact.uri || !isEqualOrParent(artifact.uri, sessionStateRoot)) {+ continue;+ }+ const artifactSessionId = relativePath(sessionStateRoot, artifact.uri)?.split('/')[0];+ if (artifactSessionId) {+ homes.push({ uri: joinPath(sessionStateRoot, artifactSessionId), label });+ }+ }+ }+ }+ this._resourceLabelHomes.set(homes);+ };+ this._register(this._onDidChangeSessionsImmediately(updateResourceLabelHomes));+ this._register(this._onDidChangeDraftSessions.event(updateResourceLabelHomes));+ updateResourceLabelHomes(); this._register(autorun(reader => { this._automationSessionResources = new ResourceSet(this.automations.runs.read(reader).flatMap(run => run.sessionResource ? [run.sessionResource] : [])); const changed = this.syncAutomationSessionMarkers(this._sessionCache.values());src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts84 + / 4 −
@@ -60,6 +60,9 @@ import { IPullRequestIconCache, PullRequestIconCache } from '../../../../github/ import { computePullRequestIcon, GitHubPullRequestState, type IGitHubPullRequest } from '../../../../github/common/types.js'; import { IWorkbenchEnvironmentService } from '../../../../../../workbench/services/environment/common/environmentService.js'; import { IAgentHostSessionsProvider } from '../../../../../common/agentHostSessionsProvider.js';+import { IPathService } from '../../../../../../workbench/services/path/common/pathService.js';+import { MockLabelService } from '../../../../../../workbench/services/label/test/common/mockLabelService.js';+import { TestPathService } from '../../../../../../workbench/test/browser/workbenchTestServices.js'; // ---- Mock IAgentHostService ------------------------------------------------- @@ -438,7 +441,7 @@ function createSchemaDefaultConfigurationService(): TestConfigurationService { function createProvider(disposables: DisposableStore, agentHostService: MockAgentHostService, contributions = [ { type: 'agent-host-copilotcli', name: 'copilot', displayName: 'Copilot', description: 'test', icon: undefined },-], options?: { sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise<ChatSendResult>; acquireOrLoadSession?: (resource: URI) => Promise<IChatModelReference | undefined>; languageModelIds?: string[]; lookupLanguageModel?: (modelId: string) => ILanguageModelChatMetadata | undefined; hiddenLanguageModelIds?: ReadonlySet<string>; languageModelVisibilityChanges?: Event<void>; openSession?: boolean; configurationService?: IConfigurationService; activeSession?: IObservable<IActiveSession | undefined>; visibleSessions?: IObservable<readonly (IActiveSession | undefined)[]>; activeClient?: Omit<SessionActiveClient, 'clientId'>; activeClientAgents?: IObservable<readonly AgentCustomization[]>; activeClientScope?: (sessionType: string, roots: readonly URI[]) => IAgentCustomizationScope; storageService?: IStorageService; isSessionsWindow?: boolean; confirmDelete?: boolean; workspaceTrusted?: boolean; requestWorkspaceTrust?: (uri: URI) => Promise<boolean>; workspaceTrustBarrier?: DeferredPromise<void>; workspaceTrustError?: Error; setUrisTrust?: (uris: URI[], trusted: boolean) => Promise<void>; gitHubService?: IGitHubService; devContainerAgentHostService?: IDevContainerAgentHostService; sessionsProvidersService?: ISessionsProvidersService }): LocalAgentHostSessionsProvider {+], options?: { sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise<ChatSendResult>; acquireOrLoadSession?: (resource: URI) => Promise<IChatModelReference | undefined>; languageModelIds?: string[]; lookupLanguageModel?: (modelId: string) => ILanguageModelChatMetadata | undefined; hiddenLanguageModelIds?: ReadonlySet<string>; languageModelVisibilityChanges?: Event<void>; openSession?: boolean; configurationService?: IConfigurationService; activeSession?: IObservable<IActiveSession | undefined>; visibleSessions?: IObservable<readonly (IActiveSession | undefined)[]>; activeClient?: Omit<SessionActiveClient, 'clientId'>; activeClientAgents?: IObservable<readonly AgentCustomization[]>; activeClientScope?: (sessionType: string, roots: readonly URI[]) => IAgentCustomizationScope; storageService?: IStorageService; isSessionsWindow?: boolean; confirmDelete?: boolean; workspaceTrusted?: boolean; requestWorkspaceTrust?: (uri: URI) => Promise<boolean>; workspaceTrustBarrier?: DeferredPromise<void>; workspaceTrustError?: Error; setUrisTrust?: (uris: URI[], trusted: boolean) => Promise<void>; gitHubService?: IGitHubService; devContainerAgentHostService?: IDevContainerAgentHostService; sessionsProvidersService?: ISessionsProvidersService; pathService?: IPathService; labelService?: ILabelService }): LocalAgentHostSessionsProvider { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IAgentHostService, agentHostService);@@ -485,9 +488,7 @@ function createProvider(disposables: DisposableStore, agentHostService: MockAgen onDidChangeLanguageModels: Event.None, onDidChangeModelVisibility: options?.languageModelVisibilityChanges ?? Event.None, });- instantiationService.stub(ILabelService, {- getUriLabel: (uri: URI) => uri.path,- });+ instantiationService.stub(ILabelService, options?.labelService ?? new MockLabelService()); instantiationService.stub(ILogService, new NullLogService()); const storageService = options?.storageService ?? disposables.add(new InMemoryStorageService()); instantiationService.stub(IStorageService, storageService);@@ -498,6 +499,7 @@ function createProvider(disposables: DisposableStore, agentHostService: MockAgen override findPullRequestNumberByHeadBranch = async () => undefined; }()); instantiationService.stub(IPullRequestIconCache, instantiationService.createInstance(PullRequestIconCache));+ instantiationService.stub(IPathService, options?.pathService ?? new TestPathService(URI.file('/home/test'))); const activeSessionObs = options?.activeSession ?? constObservable<IActiveSession | undefined>(undefined); const visibleSessionsObs = options?.visibleSessions ?? constObservable<readonly (IActiveSession | undefined)[]>([]); instantiationService.stub(ISessionsService, new class extends mock<ISessionsService>() {@@ -5722,6 +5724,84 @@ suite('LocalAgentHostSessionsProvider', () => { }); })); + test('registers provider-neutral resource label homes for quick chats and provider state', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {+ const claudeHome = URI.file('/home/test/.agent/chats/claude-session');+ agentHost.addSession(createSession('claude-session', { provider: 'claude', summary: 'Claude Quick Chat', quickChat: true, workingDirectory: claudeHome }));+ agentHost.addSession(createSession('copilot-session', { summary: 'Copilot Session' }));+ const pathService = new TestPathService(URI.file('/home/test'));+ const labelService = new MockLabelService();++ const provider = createProvider(disposables, agentHost, undefined, { pathService, labelService });+ provider.getSessions();+ await timeout(0);+ const getHomeLabel = (resource: URI): string | undefined => {+ const home = labelService.getUriHome(resource);+ return home ? labelService.getUriLabel(home) : undefined;+ };++ assert.deepStrictEqual({+ quickChat: getHomeLabel(URI.joinPath(claudeHome, 'artifact.md')),+ copilotState: getHomeLabel(URI.file('/home/test/.copilot/session-state/copilot-session/artifact.md')),+ }, {+ quickChat: 'claude/Session',+ copilotState: 'Copilot/Session',+ });++ provider.dispose();+ assert.deepStrictEqual({+ quickChat: labelService.getUriHome(URI.joinPath(claudeHome, 'artifact.md')),+ copilotState: labelService.getUriHome(URI.file('/home/test/.copilot/session-state/copilot-session/artifact.md')),+ }, {+ quickChat: undefined,+ copilotState: undefined,+ });+ }));++ test('registers the session state home before a quick chat is materialized', () => {+ const pathService = new TestPathService(URI.file('/home/test'));+ const labelService = new MockLabelService();+ const provider = createProvider(disposables, agentHost, undefined, { pathService, labelService });++ const session = provider.createQuickChat(provider.sessionTypes[0].id);+ const rawId = AgentSession.id(session.resource);+ const resource = URI.file(`/home/test/.copilot/chats/${rawId}/files/plan.md`);++ const home = labelService.getUriHome(resource);+ assert.deepStrictEqual({+ home: home?.toString(),+ label: home ? labelService.getUriLabel(home) : undefined,+ }, {+ home: URI.file(`/home/test/.copilot/chats/${rawId}`).toString(),+ label: 'Copilot/Session',+ });+ });++ test('registers the SDK session state home from recorded artifacts', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {+ const metadata = createSession('ahp-session', { summary: 'Artifact Session' });+ agentHost.addSession({+ ...metadata,+ _meta: withSessionArtifacts(metadata._meta, [{+ id: 'artifact',+ type: SessionArtifactType.File,+ label: 'Plan',+ isArtifact: true,+ uri: 'file:///home/test/.copilot/session-state/sdk-session/files/plan.md',+ }])+ });+ const labelService = new MockLabelService();+ const provider = createProvider(disposables, agentHost, undefined, {+ pathService: new TestPathService(URI.file('/home/test')),+ labelService,+ });+ provider.getSessions();+ await timeout(0);++ assert.strictEqual(+ labelService.getUriHome(URI.file('/home/test/.copilot/session-state/sdk-session/files/plan.md'))?.toString(),+ URI.file('/home/test/.copilot/session-state/sdk-session').toString()+ );+ }));+ test('session adapter uses raw ID as fallback title', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { agentHost.addSession(createSession('abcdef1234567890')); src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts21 + / 0 −
@@ -54,6 +54,9 @@ import { structuralEquals } from '../../../../../base/common/equals.js'; import { CopilotCLISessionType } from '../../agentHost/browser/baseAgentHostSessionsProvider.js'; import { createChangesets } from './copilotChatSessionsChangesets.js'; import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js';+import { IPathService } from '../../../../../workbench/services/path/common/pathService.js';+import { ResourceLabelHomeStore } from '../../../../../workbench/services/label/common/resourceLabelHomeStore.js';+import { buildLocalSessionStateUri } from '../../../../../workbench/contrib/chat/browser/copilotCliEventsUri.js'; import { IAgentHostEnablementService } from '../../../../../platform/agentHost/common/agentHostEnablementService.js'; import { isCloudSandboxEnabled } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; import { getWorkbenchContribution } from '../../../../../workbench/common/contributions.js';@@ -1422,6 +1425,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions /** Cache of adapted sessions, keyed by resource URI string. */ private readonly _sessionCache = new Map<string, AgentSessionAdapter | CopilotCLISession | RemoteNewSession>();+ private readonly _resourceLabelHomes: ResourceLabelHomeStore; /** * Resources of committed sessions that are currently in-flight (i.e.@@ -1500,11 +1504,14 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions @ILabelService private readonly labelService: ILabelService, @IChatModeService private readonly chatModeService: IChatModeService, @IUriIdentityService private readonly uriIdentityService: IUriIdentityService,+ @IPathService private readonly pathService: IPathService, @IGitService private readonly gitService: IGitService, ) { super();+ this._resourceLabelHomes = this._register(this.instantiationService.createInstance(ResourceLabelHomeStore)); this._multiChatEnabled = this.configurationService.getValue<boolean>(COPILOT_MULTI_CHAT_SETTING) ?? true;+ this._register(this._onDidChangeSessions.event(() => this._updateResourceLabelHomes())); this._register(runOnChange(this.agentHostEnablementService.enabled, () => { this._onDidChangeSessionTypes.fire();@@ -2963,6 +2970,20 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } } + private _updateResourceLabelHomes(): void {+ const sessionStateRoot = buildLocalSessionStateUri(this.pathService.userHome({ preferLocal: true }));+ const homes: { readonly uri: URI; readonly label: string }[] = [];+ for (const session of this._sessionCache.values()) {+ if (session.sessionType === SessionType.CopilotCLI) {+ const rawId = session.resource.path.replace(/^\//, '');+ if (rawId) {+ homes.push({ uri: URI.joinPath(sessionStateRoot, rawId), label: `${CopilotCLISessionType.label}/${localize('sessionHome', "Session")}` });+ }+ }+ }+ this._resourceLabelHomes.set(homes);+ }+ private _refreshSessionCacheMultiChat( addedData: ICopilotChatSession[], removedData: ICopilotChatSession[],src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts22 + / 8 −
@@ -44,6 +44,9 @@ import { ChatConfiguration, ChatPermissionLevel } from '../../../../../../workbe import { CopilotChatSessionsProvider, COPILOT_PROVIDER_ID, CopilotCloudSessionType, ICopilotChatSession } from '../../browser/copilotChatSessionsProvider.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js';+import { IPathService } from '../../../../../../workbench/services/path/common/pathService.js';+import { MockLabelService } from '../../../../../../workbench/services/label/test/common/mockLabelService.js';+import { TestPathService } from '../../../../../../workbench/test/browser/workbenchTestServices.js'; import { IUriIdentityService } from '../../../../../../platform/uriIdentity/common/uriIdentity.js'; import { extUri } from '../../../../../../base/common/resources.js'; import { CopilotCLISessionType } from '../../../agentHost/browser/baseAgentHostSessionsProvider.js';@@ -240,7 +243,7 @@ function createProviderWithConfig( disposables: DisposableStore, model: MockAgentSessionsModel, opts?: ICreateProviderOptions,-): { provider: CopilotChatSessionsProvider; configService: TestConfigurationService; agentHostEnabled: ISettableObservable<boolean> } {+): { provider: CopilotChatSessionsProvider; configService: TestConfigurationService; agentHostEnabled: ISettableObservable<boolean>; labelService: MockLabelService } { const instantiationService = disposables.add(new TestInstantiationService()); const configService = new TestConfigurationService();@@ -304,16 +307,16 @@ function createProviderWithConfig( }); // Stub IInstantiationService so provider can use createInstance for CopilotCLISession instantiationService.stub(IInstantiationService, instantiationService);- instantiationService.stub(ILabelService, {- getUriLabel: (uri: URI) => uri.path,- });+ const labelService = new MockLabelService();+ instantiationService.stub(ILabelService, labelService);+ instantiationService.stub(IPathService, new TestPathService(URI.file('/home/test'))); instantiationService.stub(IUriIdentityService, { extUri }); instantiationService.stub(IGitService, opts?.gitService ?? { repositories: [], openRepository: async () => undefined }); instantiationService.stub(IGitHubService, opts?.gitHubService ?? new TestGitHubService()); instantiationService.stub(IPullRequestIconCache, opts?.pullRequestIconCache ?? new TestPullRequestIconCache()); const provider = disposables.add(instantiationService.createInstance(CopilotChatSessionsProvider));- return { provider, configService, agentHostEnabled };+ return { provider, configService, agentHostEnabled, labelService }; } // ---- Provider factory for send/cancel tests ---------------------------------@@ -394,9 +397,8 @@ function createProviderForSendTests( instantiationService.stub(ILanguageModelToolsService, { toToolReferences: () => [] }); instantiationService.stub(IGitService, { openRepository: async () => undefined }); instantiationService.stub(IInstantiationService, instantiationService);- instantiationService.stub(ILabelService, {- getUriLabel: (uri: URI) => uri.path,- });+ instantiationService.stub(ILabelService, new MockLabelService());+ instantiationService.stub(IPathService, new TestPathService(URI.file('/home/test'))); instantiationService.stub(IUriIdentityService, { extUri }); instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(opts?.agentHostEnabled ?? true), managedSandboxEnforced: constObservable(false) }); instantiationService.stub(IContextKeyService, new MockContextKeyService());@@ -494,6 +496,18 @@ suite('CopilotChatSessionsProvider', () => { assert.strictEqual(sessions.length, 2); }); + test('registers Copilot CLI session state directories as resource label homes', () => {+ const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });+ model.addSession(createMockAgentSession(resource));++ const { labelService } = createProviderWithConfig(disposables, model);++ assert.strictEqual(+ labelService.getUriHome(URI.file('/home/test/.copilot/session-state/session-1/artifact.md'))?.toString(),+ URI.file('/home/test/.copilot/session-state/session-1').toString()+ );+ });+ test('getSessions does not emit session changes while reading the initial cache', () => { const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session' }); model.addSession(createMockAgentSession(resource));src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts11 + / 0 −
@@ -34,6 +34,7 @@ import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browse import { IChatService } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js';+import { ResourceLabelHomeStore } from '../../../../../workbench/services/label/common/resourceLabelHomeStore.js'; import { IAgentHostConnectProgress, IAgentHostGroup } from '../../../../common/agentHostSessionsProvider.js'; import { buildAgentHostSessionWorkspace, readBranchProtectionPatterns } from '../../../../common/agentHostSessionWorkspace.js'; import { IGitHubInfo, ISession, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_REMOTE } from '../../../../services/sessions/common/session.js';@@ -170,6 +171,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid private _connection: IAgentConnection | undefined; private _defaultDirectory: string | undefined; private readonly _connectionListeners = this._register(new DisposableStore());+ private readonly _resourceLabelHomes: ResourceLabelHomeStore; private readonly _connectionAuthority: string; private readonly _connectOnDemand: (() => Promise<void>) | undefined; private readonly _disconnectOnDemand: (() => Promise<void>) | undefined;@@ -210,6 +212,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid @IWorkspaceTrustManagementService workspaceTrustManagementService: IWorkspaceTrustManagementService, ) { super(chatSessionsService, chatService, chatWidgetService, languageModelsService, _configurationService, logService, gitHubService, instantiationService, sessionsService, activeClientService, storageService, dialogService, workspaceTrustManagementService);+ this._resourceLabelHomes = this._register(instantiationService.createInstance(ResourceLabelHomeStore)); this._connectionAuthority = agentHostAuthority(config.address); this._connectOnDemand = config.connectOnDemand;@@ -220,6 +223,13 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid this._defaultChangesetKind = config.defaultChangesetKind; this.onDidReportConnectProgress = config.onDidReportConnectProgress; this.canConnectOnDemand = !!config.connectOnDemand;+ const updateResourceLabelHomes = () => {+ const homes = this.getResourceLabelHomes();+ this._resourceLabelHomes.set(homes);+ };+ this._register(this._onDidChangeSessionsImmediately(updateResourceLabelHomes));+ this._register(this._onDidChangeDraftSessions.event(updateResourceLabelHomes));+ updateResourceLabelHomes(); const displayName = config.name || config.address; this.id = `agenthost-${this._connectionAuthority}`;@@ -252,6 +262,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid }]; this._enableSessionCachePersistence(this._storageKey, `${CACHED_SESSIONS_STORAGE_PREFIX_LEGACY}${this._connectionAuthority}`);+ updateResourceLabelHomes(); this._register(this._configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration('git.branchProtection')) { this._refreshSessionWorkspaces();src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts2 + / 3 −
@@ -46,6 +46,7 @@ import { CopilotCLISessionType } from '../../../agentHost/browser/baseAgentHostS import { IObservable, constObservable } from '../../../../../../base/common/observable.js'; import { IActiveSession } from '../../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../../../services/sessions/browser/sessionsService.js';+import { MockLabelService } from '../../../../../../workbench/services/label/test/common/mockLabelService.js'; // ---- Mock connection -------------------------------------------------------- @@ -261,9 +262,7 @@ function createProvider(disposables: DisposableStore, connection: MockAgentConne }); instantiationService.stub(IStorageService, overrides?.storageService ?? disposables.add(new InMemoryStorageService())); instantiationService.stub(IProgressService, {});- instantiationService.stub(ILabelService, {- getUriLabel: (uri: URI) => uri.path,- });+ instantiationService.stub(ILabelService, new MockLabelService()); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IGitHubService, new class extends mock<IGitHubService>() { override findPullRequestNumberByHeadBranch = async () => undefined;src/vs/workbench/browser/parts/editor/breadcrumbsModel.ts24 + / 4 −
@@ -12,6 +12,7 @@ import { dirname, isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { FileKind } from '../../../../platform/files/common/files.js';+import { ILabelService } from '../../../../platform/label/common/label.js'; import { IWorkspaceContextService, IWorkspaceFolder, WorkbenchState } from '../../../../platform/workspace/common/workspace.js'; import { BreadcrumbsConfig } from './breadcrumbs.js'; import { IEditorPane } from '../../../common/editor.js';@@ -30,7 +31,7 @@ export class FileElement { } } -type FileInfo = { path: FileElement[]; folder?: IWorkspaceFolder };+type FileInfo = { path: FileElement[]; folder?: IWorkspaceFolder; home?: URI }; export class OutlineElement2 { constructor(@@ -60,13 +61,19 @@ export class BreadcrumbsModel { @IWorkspaceContextService private readonly _workspaceService: IWorkspaceContextService, @IWorkspaceFolderLabelService private readonly _workspaceFolderLabelService: IWorkspaceFolderLabelService, @IOutlineService private readonly _outlineService: IOutlineService,+ @ILabelService private readonly _labelService: ILabelService, ) { this._cfgFilePath = BreadcrumbsConfig.FilePath.bindTo(configurationService); this._cfgSymbolPath = BreadcrumbsConfig.SymbolPath.bindTo(configurationService); this._disposables.add(this._cfgFilePath.onDidChange(_ => this._onDidUpdate.fire(this))); this._disposables.add(this._cfgSymbolPath.onDidChange(_ => this._onDidUpdate.fire(this))); this._workspaceService.onDidChangeWorkspaceFolders(this._onDidChangeWorkspaceFolders, this, this._disposables);+ this._disposables.add(this._labelService.onDidChangeFormatters(e => {+ if (e.scheme === this.resource.scheme) {+ this._updateFileInfo();+ }+ })); this._fileInfo = this._initFilePathInfo(resource); if (editor) {@@ -87,7 +94,7 @@ export class BreadcrumbsModel { } isRelative(): boolean {- return Boolean(this._fileInfo.folder);+ return Boolean(this._fileInfo.folder || this._fileInfo.home); } getElements(): ReadonlyArray<FileElement | OutlineElement2> {@@ -131,12 +138,13 @@ export class BreadcrumbsModel { const info: FileInfo = { folder: this._workspaceService.getWorkspaceFolder(uri) ?? undefined,- path: []+ path: [],+ home: this._labelService.getUriHome(uri), }; let uriPrefix: URI | null = uri; while (uriPrefix && uriPrefix.path !== '/') {- if (info.folder && isEqual(info.folder.uri, uriPrefix)) {+ if ((info.folder && isEqual(info.folder.uri, uriPrefix)) || (info.home && isEqual(info.home, uriPrefix))) { break; } info.path.unshift(new FileElement(uriPrefix, info.path.length === 0 ? FileKind.FILE : FileKind.FOLDER));@@ -147,6 +155,14 @@ export class BreadcrumbsModel { } } + if (info.home) {+ const separator = this._labelService.getSeparator(info.home.scheme, info.home.authority);+ const labels = this._labelService.getUriLabel(info.home).split(separator).filter(Boolean);+ for (let index = labels.length - 1; index >= 0; index--) {+ info.path.unshift(new FileElement(info.home, index === 0 ? FileKind.ROOT_FOLDER : FileKind.FOLDER, labels[index]));+ }+ }+ if (info.folder && this._workspaceService.getWorkbenchState() === WorkbenchState.WORKSPACE) { const folderCount = this._workspaceService.getWorkspace().folders.length; if (folderCount > 1 || isEqual(info.folder.uri, this.resource)) {@@ -161,6 +177,10 @@ export class BreadcrumbsModel { } private _onDidChangeWorkspaceFolders() {+ this._updateFileInfo();+ }++ private _updateFileInfo(): void { this._fileInfo = this._initFilePathInfo(this.resource); this._onDidUpdate.fire(this); }src/vs/workbench/services/configurationResolver/test/electron-browser/configurationResolverService.test.ts3 + / 0 −
@@ -809,6 +809,9 @@ class MockCommandService implements ICommandService { class MockLabelService implements ILabelService { _serviceBrand: undefined;+ getUriHome(): undefined {+ return undefined;+ } getUriLabel(resource: URI, options?: { relative?: boolean | undefined; noPrefix?: boolean | undefined }): string { return normalize(resource.fsPath); }src/vs/workbench/services/label/common/labelService.ts57 + / 23 −
@@ -12,7 +12,7 @@ import { Extensions as WorkbenchExtensions, IWorkbenchContributionsRegistry, IWo import { Registry } from '../../../../platform/registry/common/platform.js'; import { IWorkbenchEnvironmentService } from '../../environment/common/environmentService.js'; import { IWorkspaceContextService, IWorkspace, isWorkspace, ISingleFolderWorkspaceIdentifier, isSingleFolderWorkspaceIdentifier, isWorkspaceIdentifier, IWorkspaceIdentifier, toWorkspaceIdentifier, WORKSPACE_EXTENSION, isUntitledWorkspace, isTemporaryWorkspace } from '../../../../platform/workspace/common/workspace.js';-import { basenameOrAuthority, basename, joinPath, dirname } from '../../../../base/common/resources.js';+import { basenameOrAuthority, basename, dirname, isEqualOrParent, joinPath, relativePath } from '../../../../base/common/resources.js'; import { tildify, getPathLabel } from '../../../../base/common/labels.js'; import { ILabelService, ResourceLabelFormatter, ResourceLabelFormatting, IFormatterChangeEvent, Verbosity } from '../../../../platform/label/common/label.js'; import { ExtensionsRegistry } from '../../extensions/common/extensionsRegistry.js';@@ -178,36 +178,70 @@ export class LabelService extends Disposable implements ILabelService { this.userHome = await this.pathService.userHome(); } - findFormatting(resource: URI): ResourceLabelFormatting | undefined {- let bestResult: ResourceLabelFormatter | undefined;+ getUriHome(resource: URI): URI | undefined {+ const formatter = this.findHomeFormatter(resource);+ return formatter?.home ? resource.with({ path: formatter.home, query: null, fragment: null }) : undefined;+ } + private findHomeFormatter(resource: URI): ResourceLabelFormatter | undefined {+ let bestResult: ResourceLabelFormatter | undefined; for (const formatter of this.formatters) {- if (formatter.scheme === resource.scheme) {- if (!formatter.authority && (!bestResult || formatter.priority)) {- bestResult = formatter;- continue;- }-- if (!formatter.authority) {- continue;- }-- if (match(formatter.authority, resource.authority, { ignoreCase: true }) &&- (- !bestResult?.authority ||- formatter.authority.length > bestResult.authority.length ||- ((formatter.authority.length === bestResult.authority.length) && formatter.priority)- )- ) {- bestResult = formatter;- }+ if (!formatter.home || formatter.scheme !== resource.scheme || (formatter.authority && !match(formatter.authority, resource.authority, { ignoreCase: true }))) {+ continue;+ }+ if (!isEqualOrParent(resource, resource.with({ path: formatter.home }))) {+ continue;+ }+ const authorityLength = formatter.authority?.length ?? 0;+ const bestAuthorityLength = bestResult?.authority?.length ?? 0;+ const bestHomeLength = bestResult?.home?.length ?? 0;+ if (!bestResult ||+ formatter.home.length > bestHomeLength ||+ (formatter.home.length === bestHomeLength && authorityLength > bestAuthorityLength) ||+ (formatter.home.length === bestHomeLength && authorityLength === bestAuthorityLength && formatter.priority)+ ) {+ bestResult = formatter; } }+ return bestResult;+ } - return bestResult ? bestResult.formatting : undefined;+ findFormatting(resource: URI): ResourceLabelFormatting | undefined {+ let bestResult: ResourceLabelFormatter | undefined;+ for (const formatter of this.formatters) {+ if (formatter.home || formatter.scheme !== resource.scheme) {+ continue;+ }+ if (!formatter.authority && (!bestResult || formatter.priority)) {+ bestResult = formatter;+ continue;+ }+ if (!formatter.authority) {+ continue;+ }+ if (match(formatter.authority, resource.authority, { ignoreCase: true }) &&+ (+ !bestResult?.authority ||+ formatter.authority.length > bestResult.authority.length ||+ ((formatter.authority.length === bestResult.authority.length) && formatter.priority)+ )+ ) {+ bestResult = formatter;+ }+ }+ return bestResult?.formatting; } getUriLabel(resource: URI, options: { relative?: boolean; noPrefix?: boolean; separator?: '/' | '\\'; appendWorkspaceSuffix?: boolean } = {}): string {+ const homeFormatter = this.findHomeFormatter(resource);+ if (homeFormatter?.home) {+ const home = resource.with({ path: homeFormatter.home, query: null, fragment: null });+ const separator = options.separator ?? homeFormatter.formatting.separator;+ const path = relativePath(home, resource);+ const label = this.formatUri(home, homeFormatter.formatting);+ return path ? `${label}${separator}${this.adjustPathSeparators(path, separator)}` : label;+ }+ let formatting = this.findFormatting(resource); if (formatting && options.separator) { // mixin separator if defined from the outsidesrc/vs/workbench/services/label/common/resourceLabelHomeStore.tsadded50 + / 0 −
@@ -0,0 +1,50 @@+/*---------------------------------------------------------------------------------------------+ * Copyright (c) Microsoft Corporation. All rights reserved.+ * Licensed under the MIT License. See License.txt in the project root for license information.+ *--------------------------------------------------------------------------------------------*/++import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js';+import { getComparisonKey } from '../../../../base/common/resources.js';+import { URI } from '../../../../base/common/uri.js';+import { ILabelService } from '../../../../platform/label/common/label.js';++export interface IResourceLabelHome {+ readonly uri: URI;+ readonly label: string;+}++export class ResourceLabelHomeStore extends Disposable {++ private readonly registrations = this._register(new DisposableMap<string>());++ constructor(+ @ILabelService private readonly labelService: ILabelService,+ ) {+ super();+ }++ set(homes: readonly IResourceLabelHome[]): void {+ const keyFor = (home: IResourceLabelHome) => `${getComparisonKey(home.uri)}\0${home.label}`;+ const homeKeys = new Set(homes.map(keyFor));+ for (const home of homes) {+ const key = keyFor(home);+ if (!this.registrations.has(key)) {+ this.registrations.set(key, this.labelService.registerFormatter({+ scheme: home.uri.scheme,+ authority: home.uri.authority || undefined,+ home: home.uri.path,+ priority: true,+ formatting: {+ label: home.label,+ separator: this.labelService.getSeparator(home.uri.scheme, home.uri.authority),+ },+ }));+ }+ }+ for (const [key] of this.registrations) {+ if (!homeKeys.has(key)) {+ this.registrations.deleteAndDispose(key);+ }+ }+ }+}src/vs/workbench/services/label/test/browser/label.test.ts76 + / 0 −
@@ -18,6 +18,7 @@ import { ResourceLabelFormatter } from '../../../../../platform/label/common/lab import { sep } from '../../../../../base/common/path.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js';+import { ResourceLabelHomeStore } from '../../common/resourceLabelHomeStore.js'; suite('URI Label', () => { let labelService: LabelService;@@ -83,6 +84,81 @@ suite('URI Label', () => { assert.strictEqual(labelService.getUriBasenameLabel(uri1), 'END'); }); + test('returns the most specific registered resource label home', () => {+ const outer = URI.file('/home/test/.agent');+ const inner = URI.file('/home/test/.agent/sessions/session-id');+ const outerRegistration = labelService.registerFormatter({ scheme: outer.scheme, home: outer.path, formatting: { label: 'Agent', separator: '/' } });+ const innerRegistration = labelService.registerFormatter({ scheme: inner.scheme, home: inner.path, formatting: { label: 'Session', separator: '/' } });++ const resource = URI.file('/home/test/.agent/sessions/session-id/file.md');+ assert.deepStrictEqual({+ home: labelService.getUriHome(resource)?.toString(),+ label: labelService.getUriLabel(resource),+ }, {+ home: inner.toString(),+ label: 'Session/file.md',+ });++ innerRegistration.dispose();+ assert.deepStrictEqual({+ home: labelService.getUriHome(resource)?.toString(),+ label: labelService.getUriLabel(resource),+ }, {+ home: outer.toString(),+ label: 'Agent/sessions/session-id/file.md',+ });+ outerRegistration.dispose();+ });++ test('ignores resource label homes for a different authority', () => {+ const mismatchedRegistration = labelService.registerFormatter({+ scheme: 'test',+ authority: 'other',+ home: '/sessions/session-id',+ formatting: { label: 'Session', separator: '/' },+ });++ assert.strictEqual(labelService.getUriHome(URI.parse('test://current/sessions/session-id/file.md')), undefined);++ mismatchedRegistration.dispose();+ });++ test('resource label homes take precedence over ordinary formatter changes', () => {+ const resource = URI.file('/home/test/.agent/sessions/session-id/file.md');+ const root = URI.file('/home/test/.agent/sessions/session-id');+ const first = labelService.registerFormatter({+ scheme: 'file',+ formatting: { label: 'FIRST${path}', separator: '/' },+ });+ const homes = new ResourceLabelHomeStore(labelService);+ homes.set([{ uri: root, label: 'Session' }]);++ assert.strictEqual(labelService.getUriLabel(resource), 'Session/file.md');++ first.dispose();+ const second = labelService.registerFormatter({+ scheme: 'file',+ formatting: { label: 'SECOND${path}', separator: '/' },+ });+ assert.strictEqual(labelService.getUriLabel(resource), 'Session/file.md');++ homes.dispose();+ second.dispose();+ });++ test('keeps equivalent resource label home registrations until all are disposed', () => {+ const root = URI.file('/home/test/.agent/sessions/session-id');+ const formatter = { scheme: root.scheme, home: root.path, formatting: { label: 'Session', separator: '/' as const } };+ const first = labelService.registerFormatter(formatter);+ const second = labelService.registerFormatter({ ...formatter });++ first.dispose();+ assert.strictEqual(labelService.getUriLabel(labelService.getUriHome(URI.file('/home/test/.agent/sessions/session-id/file.md'))!), 'Session');++ second.dispose();+ assert.strictEqual(labelService.getUriHome(URI.file('/home/test/.agent/sessions/session-id/file.md')), undefined);+ });+ test('custom authority', function () { labelService.registerFormatter({ scheme: 'vscode',src/vs/workbench/services/label/test/common/mockLabelService.ts45 + / 5 −
@@ -3,20 +3,29 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Emitter, Event } from '../../../../../base/common/event.js';-import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js';+import { Emitter } from '../../../../../base/common/event.js';+import { IDisposable } from '../../../../../base/common/lifecycle.js'; import { basename, normalize } from '../../../../../base/common/path.js';+import { isEqualOrParent } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { IFormatterChangeEvent, ILabelService, ResourceLabelFormatter, Verbosity } from '../../../../../platform/label/common/label.js'; import { IWorkspace, IWorkspaceIdentifier } from '../../../../../platform/workspace/common/workspace.js'; export class MockLabelService implements ILabelService { _serviceBrand: undefined;+ private formatters: ResourceLabelFormatter[] = [];+ private readonly _onDidChangeFormatters = new Emitter<IFormatterChangeEvent>();+ readonly onDidChangeFormatters = this._onDidChangeFormatters.event; registerCachedFormatter(formatter: ResourceLabelFormatter): IDisposable {- throw new Error('Method not implemented.');+ return this.registerFormatter(formatter); } getUriLabel(resource: URI, options?: { relative?: boolean | undefined; noPrefix?: boolean | undefined }): string {+ const formatter = this.findHomeFormatter(resource);+ if (formatter?.home) {+ const relativePath = resource.path.slice(formatter.home.length).replace(/^\//, '');+ return relativePath ? `${formatter.formatting.label}/${relativePath}` : formatter.formatting.label;+ } return normalize(resource.fsPath); } getUriBasenameLabel(resource: URI): string {@@ -35,7 +44,38 @@ export class MockLabelService implements ILabelService { return '/'; } registerFormatter(formatter: ResourceLabelFormatter): IDisposable {- return Disposable.None;+ this.formatters.push(formatter);+ this._onDidChangeFormatters.fire({ scheme: formatter.scheme });+ return {+ dispose: () => {+ this.formatters = this.formatters.filter(candidate => candidate !== formatter);+ this._onDidChangeFormatters.fire({ scheme: formatter.scheme });+ }+ }; }- readonly onDidChangeFormatters: Event<IFormatterChangeEvent> = new Emitter<IFormatterChangeEvent>().event;++ getUriHome(resource: URI): URI | undefined {+ const formatter = this.findHomeFormatter(resource);+ return formatter?.home ? resource.with({ path: formatter.home, query: null, fragment: null }) : undefined;+ }++ private findHomeFormatter(resource: URI): ResourceLabelFormatter | undefined {+ let result: ResourceLabelFormatter | undefined;+ for (const formatter of this.formatters) {+ if (!formatter.home) {+ continue;+ }+ if (formatter.scheme !== resource.scheme || (formatter.authority && formatter.authority !== resource.authority)) {+ continue;+ }+ if (!isEqualOrParent(resource, resource.with({ path: formatter.home }))) {+ continue;+ }+ if (!result || formatter.home.length > (result.home?.length ?? 0)) {+ result = formatter;+ }+ }+ return result;+ }+ }src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts1 + / 0 −
@@ -775,6 +775,7 @@ export function registerWorkbenchServices(registration: ServiceRegistration): vo getSeparator: () => '/', registerFormatter: () => ({ dispose: () => { } }), onDidChangeFormatters: () => ({ dispose: () => { } }),+ getUriHome: () => undefined, registerCachedFormatter: () => ({ dispose: () => { } }), _serviceBrand: undefined, getHostTooltip: () => '',src/vs/workbench/test/browser/parts/editor/breadcrumbModel.test.ts70 + / 9 −
@@ -14,6 +14,7 @@ import { Workspace } from '../../../../../platform/workspace/test/common/testWor import { mock } from '../../../../../base/test/common/mock.js'; import { IOutlineService } from '../../../../services/outline/browser/outline.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';+import { MockLabelService } from '../../../../services/label/test/common/mockLabelService.js'; import { IWorkspaceFolderLabelService } from '../../../../services/workspaces/common/workspaceFolderLabelService.js'; import { Emitter } from '../../../../../base/common/event.js'; @@ -26,6 +27,8 @@ suite('Breadcrumb Model', function () { return folder.uri.path.slice(folder.uri.path.lastIndexOf('/') + 1); } };+ const outlineService = new class extends mock<IOutlineService>() { };+ const labelService = new MockLabelService(); const configService = new class extends TestConfigurationService { override getValue<T>(...args: Parameters<TestConfigurationService['getValue']>): T | undefined { if (args[0] === 'breadcrumbs.filePath') {@@ -41,14 +44,18 @@ suite('Breadcrumb Model', function () { } }; + function createModel(resource: URI, workspace: TestContextService = workspaceService): BreadcrumbsModel {+ return new BreadcrumbsModel(resource, undefined, configService, workspace, workspaceFolderLabelService, outlineService, labelService);+ }+ teardown(function () { model.dispose(); }); ensureNoDisposablesAreLeakedInTestSuite(); test('file element equality includes the rendered label', function () {- model = new BreadcrumbsModel(URI.parse('foo:/bar/baz/ws/file.ts'), undefined, configService, workspaceService, workspaceFolderLabelService, new class extends mock<IOutlineService>() { });+ model = createModel(URI.parse('foo:/bar/baz/ws/file.ts')); const uri = URI.parse('foo:/worktrees/project-feature'); assert.deepStrictEqual({@@ -62,7 +69,7 @@ suite('Breadcrumb Model', function () { test('only uri, inside workspace', function () { - model = new BreadcrumbsModel(URI.parse('foo:/bar/baz/ws/some/path/file.ts'), undefined, configService, workspaceService, workspaceFolderLabelService, new class extends mock<IOutlineService>() { });+ model = createModel(URI.parse('foo:/bar/baz/ws/some/path/file.ts')); const elements = model.getElements(); assert.strictEqual(elements.length, 3);@@ -77,7 +84,7 @@ suite('Breadcrumb Model', function () { test('display uri matters for FileElement', function () { - model = new BreadcrumbsModel(URI.parse('foo:/bar/baz/ws/some/PATH/file.ts'), undefined, configService, workspaceService, workspaceFolderLabelService, new class extends mock<IOutlineService>() { });+ model = createModel(URI.parse('foo:/bar/baz/ws/some/PATH/file.ts')); const elements = model.getElements(); assert.strictEqual(elements.length, 3);@@ -92,7 +99,7 @@ suite('Breadcrumb Model', function () { test('only uri, outside workspace', function () { - model = new BreadcrumbsModel(URI.parse('foo:/outside/file.ts'), undefined, configService, workspaceService, workspaceFolderLabelService, new class extends mock<IOutlineService>() { });+ model = createModel(URI.parse('foo:/outside/file.ts')); const elements = model.getElements(); assert.strictEqual(elements.length, 2);@@ -103,13 +110,67 @@ suite('Breadcrumb Model', function () { assert.strictEqual(two.uri.toString(), 'foo:/outside/file.ts'); }); + test('shows only the path relative to a contributed resource label home', function () {+ const root = URI.file('/Users/test/.copilot/session-state/5ec17bb7-5596-41c5-9d24-4787d8b0a698');+ const registration = labelService.registerFormatter({ scheme: root.scheme, home: root.path, formatting: { label: 'Copilot/Session', separator: '/' } });+ const resource = URI.file('/Users/test/.copilot/session-state/5ec17bb7-5596-41c5-9d24-4787d8b0a698/folder/file.md');+ model = createModel(resource);++ assert.deepStrictEqual({+ isRelative: model.isRelative(),+ elements: (model.getElements() as FileElement[]).map(element => ({+ name: element.label ?? element.uri.path.slice(element.uri.path.lastIndexOf('/') + 1),+ kind: element.kind,+ }))+ }, {+ isRelative: true,+ elements: [+ { name: 'Copilot', kind: FileKind.ROOT_FOLDER },+ { name: 'Session', kind: FileKind.FOLDER },+ { name: 'folder', kind: FileKind.FOLDER },+ { name: 'file.md', kind: FileKind.FILE },+ ]+ });+ registration.dispose();+ });++ test('updates when a resource label home is contributed after model creation', function () {+ const resource = URI.file('/Users/test/.copilot/session-state/5ec17bb7-5596-41c5-9d24-4787d8b0a698/file.md');+ model = createModel(resource);+ const root = URI.file('/Users/test/.copilot/session-state/5ec17bb7-5596-41c5-9d24-4787d8b0a698');+ const registration = labelService.registerFormatter({ scheme: root.scheme, home: root.path, formatting: { label: 'Copilot/Session', separator: '/' } });++ assert.deepStrictEqual((model.getElements() as FileElement[]).map(element => ({+ name: element.label ?? element.uri.path.slice(element.uri.path.lastIndexOf('/') + 1),+ kind: element.kind,+ })), [+ { name: 'Copilot', kind: FileKind.ROOT_FOLDER },+ { name: 'Session', kind: FileKind.FOLDER },+ { name: 'file.md', kind: FileKind.FILE },+ ]);+ registration.dispose();+ });++ test('keeps the full path without a contributed resource label home', function () {+ const resource = URI.file('/Users/test/.copilot/session-state/5ec17bb7-5596-41c5-9d24-4787d8b0a698/file.md');+ model = createModel(resource);++ assert.deepStrictEqual({+ isRelative: model.isRelative(),+ names: (model.getElements() as FileElement[]).map(element => element.uri.path.slice(element.uri.path.lastIndexOf('/') + 1))+ }, {+ isRelative: false,+ names: ['Users', 'test', '.copilot', 'session-state', '5ec17bb7-5596-41c5-9d24-4787d8b0a698', 'file.md']+ });+ });+ test('omits workspace root in single-root VS Code workspace', function () { const workspace = new TestContextService(new Workspace( 'ffff', [new WorkspaceFolder({ uri: URI.parse('foo:/bar/baz/ws'), name: 'ws', index: 0 })], URI.parse('foo:/workspace.code-workspace') ));- model = new BreadcrumbsModel(URI.parse('foo:/bar/baz/ws/file.ts'), undefined, configService, workspace, workspaceFolderLabelService, new class extends mock<IOutlineService>() { });+ model = createModel(URI.parse('foo:/bar/baz/ws/file.ts'), workspace); assert.deepStrictEqual((model.getElements() as FileElement[]).map(element => ({ uri: element.uri.toString(),@@ -125,7 +186,7 @@ suite('Breadcrumb Model', function () { [new WorkspaceFolder({ uri: URI.parse('foo:/bar/baz/ws'), name: 'ws (branch)', index: 0 })], URI.parse('foo:/workspace.code-workspace') ));- model = new BreadcrumbsModel(URI.parse('foo:/bar/baz/ws/some/file.ts'), undefined, configService, workspace, workspaceFolderLabelService, new class extends mock<IOutlineService>() { });+ model = createModel(URI.parse('foo:/bar/baz/ws/some/file.ts'), workspace); assert.deepStrictEqual((model.getElements() as FileElement[]).map(element => ({ uri: element.uri.toString(),@@ -142,7 +203,7 @@ suite('Breadcrumb Model', function () { [new WorkspaceFolder({ uri: URI.parse('foo:/bar/baz/ws'), name: 'ws (branch)', index: 0 })], URI.parse('foo:/workspace.code-workspace') ));- model = new BreadcrumbsModel(URI.parse('foo:/bar/baz/ws'), undefined, configService, workspace, workspaceFolderLabelService, new class extends mock<IOutlineService>() { });+ model = createModel(URI.parse('foo:/bar/baz/ws'), workspace); assert.deepStrictEqual((model.getElements() as FileElement[]).map(element => ({ uri: element.uri.toString(),@@ -162,7 +223,7 @@ suite('Breadcrumb Model', function () { ], URI.parse('foo:/workspace.code-workspace') ));- model = new BreadcrumbsModel(URI.parse('foo:/worktrees/docs-feature/guide.md'), undefined, configService, workspace, workspaceFolderLabelService, new class extends mock<IOutlineService>() { });+ model = createModel(URI.parse('foo:/worktrees/docs-feature/guide.md'), workspace); assert.deepStrictEqual((model.getElements() as FileElement[]).map(element => ({ uri: element.uri.toString(),@@ -177,7 +238,7 @@ suite('Breadcrumb Model', function () { test('updates workspace root and label when folders change', function () { const firstFolder = new WorkspaceFolder({ uri: URI.parse('foo:/worktrees/project-feature'), name: 'project (feature)', index: 0 }); const workspace = new TestContextService(new Workspace('ffff', [firstFolder], URI.parse('foo:/workspace.code-workspace')));- model = new BreadcrumbsModel(URI.parse('foo:/worktrees/project-feature/file.ts'), undefined, configService, workspace, workspaceFolderLabelService, new class extends mock<IOutlineService>() { });+ model = createModel(URI.parse('foo:/worktrees/project-feature/file.ts'), workspace); const secondFolder = new WorkspaceFolder({ uri: URI.parse('foo:/worktrees/docs-feature'), name: 'docs (feature)', index: 1 }); workspace.setWorkspace(new Workspace('ffff', [firstFolder, secondFolder], URI.parse('foo:/workspace.code-workspace')));