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
13 changes: 5 additions & 8 deletions apps/extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { RepositoryManager, type RepositoryScope } from './services/RepositoryManager';
import { createGitWatcher } from './services/GitRepositoryWatcher';
import { BackgroundFetchService } from './services/BackgroundFetchService';
import { FileIconThemeService } from './services/FileIconThemeService';
import { ParentRepositoryScmIntegrationService } from './services/ParentRepositoryScmIntegrationService';
import { BranchStatusBar, GitLogStatusBar } from './ui';
import {
Expand Down Expand Up @@ -260,10 +261,13 @@ export async function activate(context: vscode.ExtensionContext) {
}

// Initialize providers
const fileIconThemeService = new FileIconThemeService();
context.subscriptions.push(fileIconThemeService);
const providerOptions = {
extensionUri: context.extensionUri,
context,
repositoryManager,
fileIconThemeService,
};
const provider = new CommitViewProvider(providerOptions);
const gitLogProvider = new GitLogViewProvider(providerOptions);
Expand Down Expand Up @@ -523,14 +527,7 @@ export async function activate(context: vscode.ExtensionContext) {
return;
}

void openConflictFile(
{
extensionUri: context.extensionUri,
context,
repositoryManager,
},
{ path: file.path, repoPath: file.repoPath }
);
void openConflictFile(providerOptions, { path: file.path, repoPath: file.repoPath });
}
)
);
Expand Down
40 changes: 36 additions & 4 deletions apps/extension/src/providers/BaseWebviewProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import * as vscode from 'vscode';
import type { ExtensionMethods, WebviewMethods } from '@shared/messages';
import { RpcPeer } from '@shared/rpc';
import { RepositoryManager } from '../services/RepositoryManager';
import type { FileIconThemeService } from '../services/FileIconThemeService';
import { getWebviewHtml } from '../utils/webviewHtml';
import { createRpc, ExtensionRpcHandler, type ExtensionRpcHandlerOptions } from '../rpc';

export interface WebviewProviderOptions {
extensionUri: vscode.Uri;
context: vscode.ExtensionContext;
repositoryManager: RepositoryManager;
fileIconThemeService?: FileIconThemeService;
}

/**
Expand All @@ -24,17 +26,16 @@ export abstract class BaseWebviewProvider {
* Sets up the webview with RPC and message handling.
*/
protected setupWebview(webview: vscode.Webview, onDisposed: () => boolean): void {
webview.options = {
enableScripts: true,
localResourceRoots: [this.options.extensionUri],
};
const fileIconThemeService = this.options.fileIconThemeService;
let resourceRootsKey = this.applyWebviewOptions(webview);

this._rpc = createRpc({ webview, onDisposed });

const handler = new ExtensionRpcHandler({
context: this.options.context,
repositoryManager: this.options.repositoryManager,
onDispose: this.getOnDispose(),
getFileIconTheme: fileIconThemeService ? () => fileIconThemeService.getTheme(webview) : undefined,
...this.getRpcHandlerOptions(),
});
handler.registerAll(this._rpc);
Expand All @@ -44,6 +45,37 @@ export abstract class BaseWebviewProvider {
this._rpc?.handleMessage(msg);
})
);

if (fileIconThemeService) {
this._disposables.push(
fileIconThemeService.onDidChange(() => {
if (onDisposed()) {
return;
}
// A newly installed icon theme extension needs its folder added as a resource root.
const nextKey = this.getLocalResourceRoots()
.map((root) => root.toString())
.join('|');
if (nextKey !== resourceRootsKey) {
resourceRootsKey = this.applyWebviewOptions(webview);
}
void this._rpc?.proxy.fileIconThemeChange();
})
);
}
}

private getLocalResourceRoots(): vscode.Uri[] {
return [this.options.extensionUri, ...(this.options.fileIconThemeService?.getLocalResourceRoots() ?? [])];
}

private applyWebviewOptions(webview: vscode.Webview): string {
const localResourceRoots = this.getLocalResourceRoots();
webview.options = {
enableScripts: true,
localResourceRoots,
};
return localResourceRoots.map((root) => root.toString()).join('|');
}

protected getHtml(webview: vscode.Webview): string {
Expand Down
9 changes: 9 additions & 0 deletions apps/extension/src/rpc/ExtensionRpcHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
CommitMessageGenerationRequest,
CommitMessageGenerationResult,
ConflictResolverOpenRequest,
FileIconTheme,
PushFailedResult,
PushFailureCode,
PushRequest,
Expand Down Expand Up @@ -228,6 +229,7 @@ export interface ExtensionRpcHandlerOptions {
consumePendingGitLogReveal?: () => GitLogRevealRequest | undefined;
openConflictResolver?: (file: ConflictResolverOpenRequest) => void;
updateConflictResolverTitle?: (file: RepositoryFileReference) => void;
getFileIconTheme?: () => Promise<FileIconTheme | null>;
}

/**
Expand All @@ -242,6 +244,7 @@ export class ExtensionRpcHandler {
private onChangelistFocusChange?: (focused: boolean) => void;
private openConflictResolverPanel?: (file: ConflictResolverOpenRequest) => void;
private updateConflictResolverPanelTitle?: (file: RepositoryFileReference) => void;
private fileIconThemeProvider?: () => Promise<FileIconTheme | null>;
private gitReadRpcHandler: GitReadRpcHandler;
private changelistRpcHandler: ChangelistRpcHandler;
private _lastRebaseStatus?: string;
Expand All @@ -254,6 +257,7 @@ export class ExtensionRpcHandler {
this.onChangelistFocusChange = options.onChangelistFocusChange;
this.openConflictResolverPanel = options.openConflictResolver;
this.updateConflictResolverPanelTitle = options.updateConflictResolverTitle;
this.fileIconThemeProvider = options.getFileIconTheme;
this.gitReadRpcHandler = new GitReadRpcHandler(this.repositoryManager, options.consumePendingGitLogReveal);
this.changelistRpcHandler = new ChangelistRpcHandler(
(repoPath) => this.getChangelistOperationsForRepo(repoPath),
Expand Down Expand Up @@ -718,6 +722,7 @@ export class ExtensionRpcHandler {
getCurrentUser: this.gitReadRpcHandler.getCurrentUser,
getUnpushedCommits: this.gitReadRpcHandler.getUnpushedCommits,
getWorkspaceRoot: this.gitReadRpcHandler.getWorkspaceRoot,
getFileIconTheme: this.getFileIconTheme,
getLastCommitInfo: this.gitReadRpcHandler.getLastCommitInfo,
showErrorMessage: this.showErrorMessage,
markHunkInactive: this.changelistRpcHandler.markHunkInactive,
Expand Down Expand Up @@ -1864,6 +1869,10 @@ export class ExtensionRpcHandler {
await vscode.commands.executeCommand('intelli-git.ai.openCommitPromptSettings');
};

getFileIconTheme = async (): Promise<FileIconTheme | null> => {
return this.fileIconThemeProvider ? this.fileIconThemeProvider() : null;
};

showErrorMessage = async (message: string): Promise<void> => {
vscode.window.showErrorMessage(message);
};
Expand Down
184 changes: 184 additions & 0 deletions apps/extension/src/services/FileIconThemeService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import * as vscode from 'vscode';
import type { FileIconTheme } from '@shared/messages';
import { logger } from '../utils/logger';
import {
buildFileIconTheme,
mapFileIconThemeResources,
parseJsonWithComments,
type IconThemeColorKind,
type LanguageContribution,
} from '../utils/fileIconTheme';

const DEFAULT_ICON_THEME_ID = 'vs-seti';

interface IconThemeContribution {
id: string;
path: string;
extensionUri: vscode.Uri;
}

function getColorKind(kind: vscode.ColorThemeKind): IconThemeColorKind {
switch (kind) {
case vscode.ColorThemeKind.Light:
return 'light';
case vscode.ColorThemeKind.HighContrast:
case vscode.ColorThemeKind.HighContrastLight:
return 'highContrast';
default:
return 'dark';
}
}

function getContributes(extension: vscode.Extension<unknown>): Record<string, unknown> {
const contributes = (extension.packageJSON as { contributes?: unknown } | undefined)?.contributes;
return typeof contributes === 'object' && contributes !== null ? (contributes as Record<string, unknown>) : {};
}

/**
* Resolves the user's active VS Code file icon theme so webviews can render the same file icons
* as the Explorer. Webviews cannot access the workbench icon theme directly.
*/
export class FileIconThemeService implements vscode.Disposable {
private readonly changeEmitter = new vscode.EventEmitter<void>();
public readonly onDidChange = this.changeEmitter.event;
private readonly disposables: vscode.Disposable[] = [];
private colorKind = getColorKind(vscode.window.activeColorTheme.kind);
private themePromise?: Promise<FileIconTheme | null>;
/** Last theme sent to webviews; `null` means no icon theme is set, `undefined` means none yet. */
private appliedThemeId?: string | null;

constructor() {
this.disposables.push(
this.changeEmitter,
vscode.workspace.onDidChangeConfiguration((event) => {
if (event.affectsConfiguration('workbench.iconTheme')) {
this.invalidate();
}
}),
vscode.window.onDidChangeActiveColorTheme((colorTheme) => {
const nextKind = getColorKind(colorTheme.kind);
if (nextKind !== this.colorKind) {
this.colorKind = nextKind;
this.invalidate();
}
}),
vscode.extensions.onDidChange(() => this.invalidate())
);
}

/**
* Folders that must be readable by webviews to load icon theme images and fonts.
*/
public getLocalResourceRoots(): vscode.Uri[] {
const roots = new Map<string, vscode.Uri>();
for (const contribution of this.getIconThemeContributions()) {
roots.set(contribution.extensionUri.toString(), contribution.extensionUri);
}
return Array.from(roots.values());
}

/**
* Returns the active theme with resource URIs mapped for the given webview.
* Returns null when no icon theme is set or the theme cannot be resolved, so webviews use
* the bundled icons.
*/
public async getTheme(webview: vscode.Webview): Promise<FileIconTheme | null> {
this.themePromise ??= this.loadTheme();
const theme = await this.themePromise;
if (!theme) {
return null;
}
return mapFileIconThemeResources(theme, (uri) => webview.asWebviewUri(vscode.Uri.parse(uri)).toString());
}

private invalidate(): void {
this.themePromise = undefined;
this.changeEmitter.fire();
}

private getIconThemeContributions(): IconThemeContribution[] {
return vscode.extensions.all.flatMap((extension) => {
const iconThemes = getContributes(extension).iconThemes;
if (!Array.isArray(iconThemes)) {
return [];
}
return iconThemes.flatMap((iconTheme: unknown) => {
const { id, path } = (iconTheme ?? {}) as { id?: unknown; path?: unknown };
return typeof id === 'string' && typeof path === 'string'
? [{ id, path, extensionUri: extension.extensionUri }]
: [];
});
});
}

private getLanguageContributions(): LanguageContribution[] {
return vscode.extensions.all.flatMap((extension) => {
const languages = getContributes(extension).languages;
if (!Array.isArray(languages)) {
return [];
}
return languages.flatMap((language: unknown) => {
const { id, extensions, filenames, filenamePatterns } = (language ?? {}) as Record<string, unknown>;
if (typeof id !== 'string') {
return [];
}
return [
{
id,
extensions: Array.isArray(extensions) ? extensions : undefined,
filenames: Array.isArray(filenames) ? filenames : undefined,
filenamePatterns: Array.isArray(filenamePatterns) ? filenamePatterns : undefined,
},
];
});
});
}

private async loadTheme(): Promise<FileIconTheme | null> {
const configuredId = vscode.workspace.getConfiguration('workbench').get<string | null>('iconTheme');
if (configuredId === null) {
this.appliedThemeId = null;
return null;
}

const contributions = this.getIconThemeContributions();
const findContribution = (id: string) => contributions.find((candidate) => candidate.id === id);
let contribution = configuredId ? findContribution(configuredId) : undefined;
if (!contribution) {
// Like VS Code, keep the applied theme when the configured one is unknown;
// only the initial load falls back to the default theme.
if (this.appliedThemeId === null) {
return null;
}
contribution =
(this.appliedThemeId ? findContribution(this.appliedThemeId) : undefined) ??
findContribution(DEFAULT_ICON_THEME_ID);
}
if (!contribution) {
logger.warn(`File icon theme "${configuredId}" was not found; using bundled file icons.`);
return null;
}
this.appliedThemeId = contribution.id;

try {
const documentUri = vscode.Uri.joinPath(contribution.extensionUri, contribution.path);
const documentDir = vscode.Uri.joinPath(documentUri, '..');
const content = await vscode.workspace.fs.readFile(documentUri);
return buildFileIconTheme({
id: contribution.id,
document: parseJsonWithComments(new TextDecoder().decode(content)),
colorKind: this.colorKind,
languages: this.getLanguageContributions(),
resolveResource: (relativePath) => vscode.Uri.joinPath(documentDir, relativePath).toString(),
});
} catch (error) {
logger.warn(`Failed to load file icon theme "${contribution.id}"; using bundled file icons.`, error);
return null;
}
}

public dispose(): void {
this.disposables.forEach((disposable) => disposable.dispose());
this.disposables.length = 0;
}
}
Loading
Loading