diff --git a/AGENTS.md b/AGENTS.md index 34107e0a9d083..87c4fa92b68a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,7 +61,9 @@ pnpm run build:quick # Fast build (no linting) pnpm run build:turbo # Turbo build (no typechecking or linting) pnpm run build:extension # Build only the extension (no webviews) pnpm run build:webviews # Build only webviews +pnpm run build:tests # Build unit tests (not part of the main build) pnpm run bundle # Production bundle +pnpm run bundle:e2e # E2E tests (turbo) production bundle (with DEBUG for account simulation) pnpm run bundle:turbo # Turbo production bundle (no typechecking or linting) ``` @@ -73,40 +75,34 @@ pnpm run watch:quick # Fast watch mode (no linting) pnpm run watch:turbo # Turbo watch mode (no typechecking or linting) pnpm run watch:extension # Watch extension only pnpm run watch:webviews # Watch webviews only -pnpm run watch:tests # Watch test files +pnpm run watch:tests # Watch unit test files ``` -### Testing & Quality +### Testing ```bash -pnpm run test # Run VS Code extension tests +pnpm run test # Run unit tests (VS Code extension tests) pnpm run test:e2e # Run Playwright E2E tests -pnpm run build:tests # Build test files with esbuild +``` + +### Quality + +```bash pnpm run lint # Run ESLint with TypeScript rules pnpm run lint:fix # Auto-fix linting issues pnpm run pretty # Format code with Prettier pnpm run pretty:check # Check formatting ``` -### Specialized Commands +### Specialized Commands (typically not needed during normal development as they are part of build/watch) ```bash pnpm run generate:contributions # Generate package.json contributions from contributions.json pnpm run extract:contributions # Extract contributions from package.json to contributions.json pnpm run generate:commandTypes # Generate command types from contributions pnpm run build:icons # Build icon font from SVG sources -pnpm run web # Run extension in web environment for testing -pnpm run package # Create VSIX package ``` -### Debugging - -- Use **"Watch & Run"** launch configuration (F5) for desktop debugging -- Use **"Watch & Run (web)"** for webworker/browser debugging -- Press `Ctrl+Shift+P` → "Tasks: Run Task" → "watch" to start build task -- Tests are co-located with source files in `__tests__/` directories -- Webview changes can be refreshed without restarting extension - ## Git & Repository Requirements/Guidelines ### Committing @@ -133,7 +129,7 @@ pnpm run package # Create VSIX package Uses [Keep a Changelog](http://keepachangelog.com/) format under `[Unreleased]`. -### Section Mapping +#### Section Mapping | Change Type | Section | | ----------- | ---------- | @@ -144,7 +140,7 @@ Uses [Keep a Changelog](http://keepachangelog.com/) format under `[Unreleased]`. | Deprecation | Deprecated | | Removal | Removed | -### Entry Format +#### Entry Format ```markdown - [Verb] [description] ([#issue](url)) @@ -163,7 +159,7 @@ Uses [Keep a Changelog](http://keepachangelog.com/) format under `[Unreleased]`. - Fixes an issue where the _Home_ view would not update when switching repositories ([#4717](https://github.com/gitkraken/vscode-gitlens/issues/4717)) ``` -### Detection +#### Detection Check `[Unreleased]` section for: @@ -266,6 +262,36 @@ tests/ # E2E and Unit tests walkthroughs/ # Welcome and tips walkthroughs ``` +### Testing Structure + +**Unit Tests** + +- Tests co-located with source files in `__tests__/` directories +- Pattern: `src/path/to/__tests__/file.test.ts` +- VS Code extension tests use `@vscode/test-cli` +- Unit tests are built separately: `pnpm run build:tests` + +```bash +pnpm run test # Run unit tests (VS Code extension tests) +pnpm run watch:tests # Watch mode for tests +``` + +**End-to-End (E2E) Tests** + +- E2E tests use Playwright in `tests/e2e/` + - Fixture setup and utilities in `tests/e2e/fixtures/` + - Page objects in `tests/e2e/pageObjects/` + - Test specs in `tests/e2e/specs/` +- E2E tests are built as part of the main build, but can be built directly: `pnpm run bundle:e2e` + +```bash +pnpm run test:e2e # Run E2E tests +pnpm run bundle:e2e # Build E2E tests (production with DEBUG for account simulation) +pnpm run watch # Watch mode (includes E2E tests) +``` + +**Important**: If you have access to VS Code's `runTests` and `testFailures` tools, use them to run and debug E2E tests + ### Core Architectural Patterns **1. Service Locator (Container)** @@ -422,24 +448,6 @@ Files in or under directories named "plus" fall under `LICENSE.plus` (non-OSS): Pro features integrate with GitKraken accounts and require authentication via SubscriptionService. -### Testing Infrastructure - -**Test Structure** - -- Tests co-located with source files in `__tests__/` directories -- Pattern: `src/path/to/__tests__/file.test.ts` -- VS Code extension tests use `@vscode/test-cli` -- E2E tests use Playwright in `tests/e2e/` -- Build tests separately: `pnpm run build:tests` - -**Running Tests** - -```bash -pnpm run test # Run extension tests -pnpm run test:e2e # Run E2E tests -pnpm run watch:tests # Watch mode for tests -``` - ## Coding Standards & Style Rules ### TypeScript Configuration diff --git a/src/commands/resets.ts b/src/commands/resets.ts index c151fb2f5398e..c353c8794d6d5 100644 --- a/src/commands/resets.ts +++ b/src/commands/resets.ts @@ -216,12 +216,14 @@ export class ResetCommand extends GlCommandBase { case 'banners': await this.container.storage.delete('home:sections:collapsed'); - await this.container.storage.delete('home:walkthrough:dismissed'); - await this.container.storage.delete('mcp:banner:dismissed'); + await this.container.onboarding.resetAll(); // Deprecated keys await this.container.storage.delete('home:banners:dismissed'); await this.container.storage.delete('home:sections:dismissed'); + await this.container.storage.delete('home:walkthrough:dismissed'); + await this.container.storage.delete('mcp:banner:dismissed'); + await this.container.storage.delete('views:scm:grouped:welcome:dismissed'); break; case 'integrations': diff --git a/src/commands/walkthroughs.ts b/src/commands/walkthroughs.ts index 94b2af863cc5a..15221d5fff068 100644 --- a/src/commands/walkthroughs.ts +++ b/src/commands/walkthroughs.ts @@ -3,12 +3,12 @@ import type { WalkthroughSteps } from '../constants.js'; import { urls } from '../constants.js'; import type { Source, Sources, TelemetryEvents } from '../constants.telemetry.js'; import type { Container } from '../container.js'; +import { isWalkthroughSupported } from '../onboarding/walkthroughStateProvider.js'; import type { SubscriptionUpgradeCommandArgs } from '../plus/gk/models/subscription.js'; import type { LaunchpadCommandArgs } from '../plus/launchpad/launchpad.js'; import { command, executeCommand, executeCoreCommand } from '../system/-webview/command.js'; import { openUrl } from '../system/-webview/vscode/uris.js'; import { openWalkthrough as openWalkthroughCore } from '../system/-webview/vscode.js'; -import { isWalkthroughSupported } from '../telemetry/walkthroughStateProvider.js'; import type { ComposerWebviewShowingArgs } from '../webviews/plus/composer/registration.js'; import type { WebviewPanelShowCommandArgs } from '../webviews/webviewsController.js'; import { GlCommandBase } from './commandBase.js'; diff --git a/src/constants.commands.ts b/src/constants.commands.ts index 18edf7a0bf721..ab43d870cd2db 100644 --- a/src/constants.commands.ts +++ b/src/constants.commands.ts @@ -131,6 +131,7 @@ type InternalGlCommands = | 'gitlens.diffWithPrevious:views' | 'gitlens.diffWithWorking:command' | 'gitlens.diffWithWorking:views' + | 'gitlens.onboarding.dismiss' | 'gitlens.openCloudPatch' | 'gitlens.openOnRemote' | 'gitlens.openWalkthrough' @@ -140,7 +141,6 @@ type InternalGlCommands = | 'gitlens.showComposerPage' | 'gitlens.showInCommitGraphView' | 'gitlens.showQuickCommitDetails' - | 'gitlens.storage.store' | 'gitlens.toggleFileBlame:codelens' | 'gitlens.toggleFileBlame:mode' | 'gitlens.toggleFileBlame:statusbar' diff --git a/src/constants.onboarding.ts b/src/constants.onboarding.ts new file mode 100644 index 0000000000000..6cc2b76bb76cb --- /dev/null +++ b/src/constants.onboarding.ts @@ -0,0 +1,37 @@ +import type { OnboardingItemDefinition } from './onboarding/models/onboarding.js'; + +/** Central registry of all dismissible/onboarding keys */ +export const onboardingDefinitions = { + // Home View + 'home:integrationBanner': { schema: '17.8.0', scope: 'global' }, + 'home:walkthrough': { + schema: '17.8.0', + scope: 'global', + state: undefined as unknown as { completedSteps: string[] }, + }, + + // MCP Banner (shown in home and graph) + 'mcp:banner': { schema: '17.8.0', scope: 'global' }, + + // Rebase Editor + 'rebaseEditor:closeWarning': { schema: '17.8.0', scope: 'global' }, + + // Composer + 'composer:onboarding': { + schema: '17.8.0', + scope: 'global', + state: undefined as unknown as { stepReached: number }, + }, + + // Views + 'views:scmGrouped:welcome': { schema: '17.8.0', scope: 'global' }, +} as const satisfies Record>; + +export type OnboardingKeys = keyof typeof onboardingDefinitions; + +/** Extract state type for a specific item key */ +export type OnboardingItemState = (typeof onboardingDefinitions)[K] extends { + state: infer State; +} + ? State + : undefined; diff --git a/src/constants.storage.ts b/src/constants.storage.ts index 826e4a9ad93b0..a16161bdb7aa0 100644 --- a/src/constants.storage.ts +++ b/src/constants.storage.ts @@ -7,6 +7,7 @@ import type { GroupableTreeViewTypes, TreeViewTypes } from './constants.views.js import type { Environment } from './container.js'; import type { FeaturePreviews } from './features.js'; import type { GitRevisionRangeNotation } from './git/models/revision.js'; +import type { OnboardingStorage } from './onboarding/models/onboarding.js'; import type { OrganizationSettings } from './plus/gk/models/organization.js'; import type { PaidSubscriptionPlanIds, Subscription } from './plus/gk/models/subscription.js'; import type { IntegrationConnectedKey } from './plus/integrations/models/integration.js'; @@ -43,6 +44,10 @@ export type DeprecatedGlobalStorage = { /** @deprecated */ 'home:banners:dismissed': string[]; /** @deprecated */ + 'home:walkthrough:dismissed': boolean; + /** @deprecated */ + 'mcp:banner:dismissed': boolean; + /** @deprecated */ pendingWelcomeOnFocus: boolean; /** @deprecated */ 'plus:discountNotificationShown': boolean; @@ -55,6 +60,8 @@ export type DeprecatedGlobalStorage = { /** @deprecated */ 'views:commitDetails:dismissed': 'sidebar'[]; /** @deprecated */ + 'views:scm:grouped:welcome:dismissed': boolean; + /** @deprecated */ 'views:welcome:visible': boolean; } & { /** @deprecated */ @@ -88,16 +95,15 @@ interface GlobalStorageCore { 'gk:cli:corePath': string; 'gk:cli:path': string; 'home:sections:collapsed': string[]; - 'home:walkthrough:dismissed': boolean; - 'mcp:banner:dismissed': boolean; 'launchpad:groups:collapsed': StoredLaunchpadGroup[]; 'launchpad:indicator:hasLoaded': boolean; 'launchpad:indicator:hasInteracted': string; 'launchpadView:groups:expanded': StoredLaunchpadGroup[]; 'graph:searchMode': StoredGraphSearchMode; 'graph:useNaturalLanguageSearch': boolean; - 'views:scm:grouped:welcome:dismissed': boolean; 'integrations:configured': StoredIntegrationConfigurations; + /** Unified onboarding/dismissible UI state */ + 'onboarding:state': OnboardingStorage; } type GlobalStorageDynamic = Record<`plus:preview:${FeaturePreviews}:usages`, StoredFeaturePreviewUsagePeriod[]> & @@ -175,6 +181,8 @@ interface WorkspaceStorageCore { gitPath: string; 'graph:columns': Record; 'graph:filtersByRepo': Record; + /** Unified onboarding/dismissible UI state (workspace-scoped items) */ + 'onboarding:state': OnboardingStorage; 'remote:default': string; 'starred:branches': StoredStarred; 'starred:repositories': StoredStarred; diff --git a/src/container.ts b/src/container.ts index 53e77f4b75033..893c85f59c74d 100644 --- a/src/container.ts +++ b/src/container.ts @@ -27,6 +27,9 @@ import { GitFileSystemProvider } from './git/fsProvider.js'; import { GitProviderService } from './git/gitProviderService.js'; import type { RepositoryLocationProvider } from './git/location/repositorylocationProvider.js'; import { LineHoverController } from './hovers/lineHoverController.js'; +import { OnboardingService } from './onboarding/onboardingService.js'; +import { UsageTracker } from './onboarding/usageTracker.js'; +import { WalkthroughStateProvider } from './onboarding/walkthroughStateProvider.js'; import { AIProviderService } from './plus/ai/aiProviderService.js'; import { DraftService } from './plus/drafts/draftsService.js'; import { AccountAuthenticationProvider } from './plus/gk/authenticationProvider.js'; @@ -61,8 +64,6 @@ import { memoize } from './system/decorators/memoize.js'; import { Logger } from './system/logger.js'; import { AIFeedbackProvider } from './telemetry/aiFeedbackProvider.js'; import { TelemetryService } from './telemetry/telemetry.js'; -import { UsageTracker } from './telemetry/usageTracker.js'; -import { WalkthroughStateProvider } from './telemetry/walkthroughStateProvider.js'; import { GitTerminalLinkProvider } from './terminal/linkProvider.js'; import { GitDocumentTracker } from './trackers/documentTracker.js'; import { LineTracker } from './trackers/lineTracker.js'; @@ -204,6 +205,7 @@ export class Container { this._disposables = [ configuration, (this._storage = storage), + (this._onboarding = new OnboardingService(storage, version)), (this._telemetry = new TelemetryService(this)), (this._usage = new UsageTracker(this, storage)), configuration.onDidChangeAny(this.onAnyConfigurationChanged, this), @@ -731,6 +733,11 @@ export class Container { return this._storage; } + private readonly _onboarding: OnboardingService; + get onboarding(): OnboardingService { + return this._onboarding; + } + private _subscription: SubscriptionService; get subscription(): SubscriptionService { return this._subscription; diff --git a/src/env/node/gk/cli/integration.ts b/src/env/node/gk/cli/integration.ts index 0a099c4306321..18fa9d75798bd 100644 --- a/src/env/node/gk/cli/integration.ts +++ b/src/env/node/gk/cli/integration.ts @@ -136,7 +136,7 @@ export class GkCliIntegrationProvider implements Disposable { @gate() @log({ exit: true }) private async setupMCP(source?: Sources, force = false): Promise { - await this.container.storage.store('mcp:banner:dismissed', true); + await this.container.onboarding.dismiss('mcp:banner'); try { const result = await window.withProgress( diff --git a/src/onboarding/models/onboarding.ts b/src/onboarding/models/onboarding.ts new file mode 100644 index 0000000000000..c788b9abf2d71 --- /dev/null +++ b/src/onboarding/models/onboarding.ts @@ -0,0 +1,29 @@ +export interface OnboardingItemDefinition { + readonly scope: 'global' | 'workspace'; + /** GitLens version when this schema was introduced. Bump when data structure changes */ + readonly schema?: `${number}.${number}.${number}`; + /** GitLens version when/if this item should be re-shown if the user dismissed it before this version */ + readonly reshowAfter?: `${number}.${number}.${number}`; + /** Type marker for the data shape - value is never used at runtime */ + readonly state?: T; +} + +export interface OnboardingItem { + /** GitLens version of the stored data schema (for migrations) */ + schema?: `${number}.${number}.${number}`; + + /** ISO timestamp when dismissed */ + dismissedAt?: string; + /** GitLens version when dismissed (e.g., "17.1.0") */ + dismissedVersion?: `${number}.${number}.${number}`; + + /** Item-specific metadata */ + state?: T; +} + +export interface OnboardingStorage { + /** Whether legacy storage keys have been migrated to this state */ + migrated?: boolean; + /** Map of item ID to its state (envelope + data) */ + items: Record>; +} diff --git a/src/onboarding/onboardingMigrations.ts b/src/onboarding/onboardingMigrations.ts new file mode 100644 index 0000000000000..8e902e99f87df --- /dev/null +++ b/src/onboarding/onboardingMigrations.ts @@ -0,0 +1,25 @@ +import type { OnboardingItemState, OnboardingKeys } from '../constants.onboarding.js'; + +/** + * Migrations are maps of GitLens version → migration function + * Each migration transforms data from the previous schema to the version's schema + * Migrations run in semver order for all versions between stored and current schema + */ +export type OnboardingMigration = Record<`${number}.${number}.${number}`, (state: unknown) => T>; + +export const onboardingMigrations: { + [K in OnboardingKeys]?: OnboardingMigration>; +} = { + 'composer:onboarding': { + '17.8.0': (state: unknown) => { + const s = state as { stepReached?: number } | undefined; + return { stepReached: s?.stepReached ?? 0 }; + }, + }, + 'home:walkthrough': { + '17.8.0': (state: unknown) => { + const s = state as { completedSteps?: string[] } | undefined; + return { completedSteps: s?.completedSteps ?? [] }; + }, + }, +}; diff --git a/src/onboarding/onboardingService.ts b/src/onboarding/onboardingService.ts new file mode 100644 index 0000000000000..ea0be9c213a39 --- /dev/null +++ b/src/onboarding/onboardingService.ts @@ -0,0 +1,261 @@ +import type { Event } from 'vscode'; +import { Disposable, EventEmitter } from 'vscode'; +import type { OnboardingItemState, OnboardingKeys } from '../constants.onboarding.js'; +import { onboardingDefinitions } from '../constants.onboarding.js'; +import { registerCommand } from '../system/-webview/command.js'; +import type { Storage, StorageChangeEvent } from '../system/-webview/storage.js'; +import { updateRecordValue } from '../system/object.js'; +import { compare, fromString, fromVersion, satisfies } from '../system/version.js'; +import type { OnboardingItem, OnboardingStorage } from './models/onboarding.js'; +import { onboardingMigrations } from './onboardingMigrations.js'; + +export interface OnboardingChangeEvent { + readonly key: OnboardingKeys; + readonly dismissed: boolean; +} + +/** + * Centralized service for managing dismissible/onboarding UI state. + * + * Provides a unified API for checking, dismissing, and resetting onboarding items, + * with built-in versioning support to re-show items after significant changes, + * and typed data storage with schema migrations. + */ +export class OnboardingService implements Disposable { + private readonly _onDidChange = new EventEmitter(); + get onDidChange(): Event { + return this._onDidChange.event; + } + + private readonly _disposable: Disposable; + private _globalOnboarding: OnboardingStorage | undefined; + private _workspaceOnboarding: OnboardingStorage | undefined; + private _version: `${number}.${number}.${number}`; + + constructor( + private readonly storage: Storage, + version: string, + ) { + this._version = fromVersion(fromString(version), false); + this._disposable = Disposable.from( + this.storage.onDidChange(this.onStorageChanged, this), + registerCommand('gitlens.onboarding.dismiss', args => this.dismiss(args.id)), + ); + + void this.migrateLegacyState(); + } + + dispose(): void { + this._onDidChange.dispose(); + this._disposable.dispose(); + } + + private onStorageChanged(e: StorageChangeEvent): void { + if (e.keys.includes('onboarding:state')) { + const previousState = e.workspace ? this._workspaceOnboarding : this._globalOnboarding; + + // Invalidate the appropriate cache when storage changes externally + if (e.workspace) { + this._workspaceOnboarding = undefined; + } else { + this._globalOnboarding = undefined; + } + + if (previousState != null) { + const currentState = this.getOnboarding(e.workspace ? 'workspace' : 'global'); + + // Diff items to find what changed and fire events + const keys = new Set([...Object.keys(previousState.items), ...Object.keys(currentState.items)]); + for (const key of keys) { + const previous = previousState.items[key]?.dismissedAt != null; + const current = currentState.items[key]?.dismissedAt != null; + + if (previous !== current) { + this._onDidChange.fire({ key: key as OnboardingKeys, dismissed: current }); + } + } + } + } + } + + /** + * Checks if an onboarding item is dismissed + * Respects `reshowAfter` - if the user dismissed before that version, returns false + */ + isDismissed(key: OnboardingKeys): boolean { + const item = this.getItem(key); + if (!item?.dismissedAt) return false; + + // If reshowAfter is set and user dismissed before that version, re-show + const { reshowAfter } = onboardingDefinitions[key] as { reshowAfter?: `${number}.${number}.${number}` }; + if (reshowAfter && item.dismissedVersion) { + if (satisfies(item.dismissedVersion, `< ${reshowAfter}`)) { + return false; + } + } + + return true; + } + + /** Dismiss an onboarding item, recording the current timestamp and GitLens version */ + async dismiss(key: OnboardingKeys): Promise { + const { scope } = onboardingDefinitions[key]; + + const onboarding = this.getOnboarding(scope); + const existing = onboarding.items[key]; + + onboarding.items[key] = { + ...existing, + dismissedAt: new Date().toISOString(), + dismissedVersion: fromVersion(fromString(this._version), false), + }; + + await this.saveOnboarding(scope, onboarding); + this._onDidChange.fire({ key: key, dismissed: true }); + } + + /** Get item state, running migrations if needed */ + getItemState(key: T): OnboardingItemState | undefined { + const item = this.getItem(key); + if (!item?.state) return undefined; + + const { schema: currentSchema, scope } = onboardingDefinitions[key]; + const storedSchema = item.schema; + + // No schema defined or already current - no migration needed + if (!currentSchema || (storedSchema && compare(storedSchema, currentSchema) >= 0)) { + return item.state as OnboardingItemState; + } + + // Run migrations in version order for versions between stored and current + const migrations = onboardingMigrations[key]; + let state: OnboardingItemState = item.state; + + if (migrations) { + // Sort migration versions and run applicable ones + const migrationVersions = Object.keys(migrations).sort((a, b) => compare(a, b)); + + for (const version of migrationVersions) { + // Skip migrations at or before stored schema + if (storedSchema && compare(version, storedSchema) <= 0) { + continue; + } + + // Skip migrations after current schema + if (compare(version, currentSchema) > 0) { + continue; + } + + const migrate = migrations[version as `${number}.${number}.${number}`]; + if (migrate) { + state = migrate(state); + } + } + } + + // Persist migrated data so we don't re-migrate next time + void this.setItemStateCore(key, state, scope, currentSchema); + + return state; + } + + /** Set typed data for an item */ + async setItemState(key: T, state: OnboardingItemState): Promise { + const { scope, schema } = onboardingDefinitions[key]; + await this.setItemStateCore(key, state, scope, schema); + } + + /** Resets a specific onboarding item */ + async reset(key: OnboardingKeys): Promise { + const { scope } = onboardingDefinitions[key]; + + const onboarding = this.getOnboarding(scope); + const dismissed = onboarding.items[key]?.dismissedAt != null; + + updateRecordValue(onboarding.items, key, undefined); + + await this.saveOnboarding(scope, onboarding); + if (dismissed) { + this._onDidChange.fire({ key: key, dismissed: false }); + } + } + + /** Resets all onboarding state */ + async resetAll(): Promise { + this._globalOnboarding = { items: {} }; + this._workspaceOnboarding = { items: {} }; + await this.storage.store('onboarding:state', undefined); + await this.storage.storeWorkspace('onboarding:state', undefined); + } + + private async migrateLegacyState(): Promise { + const state = this.getOnboarding('global'); + if (state.migrated) return; + + const migrations = [ + { legacy: 'views:scm:grouped:welcome:dismissed', current: 'views:scmGrouped:welcome' }, + { legacy: 'mcp:banner:dismissed', current: 'mcp:banner' }, + { legacy: 'home:walkthrough:dismissed', current: 'home:walkthrough' }, + ] as const; + + let changed = false; + for (const { legacy, current } of migrations) { + const wasDismissed = this.storage.get(legacy as any); + if (wasDismissed) { + if (!this.isDismissed(current)) { + await this.dismiss(current); + } + await this.storage.delete(legacy as any); + changed = true; + } + } + + if (changed || !state.migrated) { + state.migrated = true; + await this.saveOnboarding('global', state); + } + } + + private async setItemStateCore( + key: T, + state: OnboardingItemState, + scope: 'global' | 'workspace', + schema: `${number}.${number}.${number}` | undefined, + ): Promise { + const onboarding = this.getOnboarding(scope); + const existing = onboarding.items[key] ?? {}; + + const updated: OnboardingItem> = { + ...existing, + schema: schema, + state: state, + }; + onboarding.items[key] = updated; + + await this.saveOnboarding(scope, onboarding); + } + + private getItem(key: T): OnboardingItem> | undefined { + const scope = onboardingDefinitions[key].scope; + return this.getOnboarding(scope).items[key] as OnboardingItem> | undefined; + } + + private getOnboarding(scope: 'global' | 'workspace'): OnboardingStorage { + if (scope === 'workspace') { + this._workspaceOnboarding ??= this.storage.getWorkspace('onboarding:state') ?? { items: {} }; + return this._workspaceOnboarding; + } + this._globalOnboarding ??= this.storage.get('onboarding:state') ?? { items: {} }; + return this._globalOnboarding; + } + + private async saveOnboarding(scope: 'global' | 'workspace', onboarding: OnboardingStorage): Promise { + if (scope === 'workspace') { + this._workspaceOnboarding = onboarding; + await this.storage.storeWorkspace('onboarding:state', onboarding); + } else { + this._globalOnboarding = onboarding; + await this.storage.store('onboarding:state', onboarding); + } + } +} diff --git a/src/telemetry/usageTracker.ts b/src/onboarding/usageTracker.ts similarity index 100% rename from src/telemetry/usageTracker.ts rename to src/onboarding/usageTracker.ts diff --git a/src/telemetry/walkthroughStateProvider.ts b/src/onboarding/walkthroughStateProvider.ts similarity index 100% rename from src/telemetry/walkthroughStateProvider.ts rename to src/onboarding/walkthroughStateProvider.ts diff --git a/src/plus/gk/utils/-webview/mcp.utils.ts b/src/plus/gk/utils/-webview/mcp.utils.ts index 261bb7c0452be..836be30f8fe6f 100644 --- a/src/plus/gk/utils/-webview/mcp.utils.ts +++ b/src/plus/gk/utils/-webview/mcp.utils.ts @@ -10,7 +10,7 @@ export function isMcpBannerEnabled(container: Container, showAutoRegistration = return false; } - return !container.storage.get('mcp:banner:dismissed', false); + return !container.onboarding.isDismissed('mcp:banner'); } const supportedApps = ['Visual Studio Code', 'Visual Studio Code - Insiders', 'Visual Studio Code - Exploration']; diff --git a/src/system/-webview/storage.ts b/src/system/-webview/storage.ts index 766aff810b718..5e5202d057a3c 100644 --- a/src/system/-webview/storage.ts +++ b/src/system/-webview/storage.ts @@ -9,20 +9,10 @@ import type { WorkspaceStorage, } from '../../constants.storage.js'; import { debug } from '../decorators/log.js'; -import { registerCommand } from './command.js'; type GlobalStorageKeys = keyof (GlobalStorage & DeprecatedGlobalStorage); type WorkspaceStorageKeys = keyof (WorkspaceStorage & DeprecatedWorkspaceStorage); -const allowedStoreCommandGlobalStorageKeys: GlobalStorageKeys[] = ['mcp:banner:dismissed']; -const allowedStoreCommandWorkspaceStorageKeys: WorkspaceStorageKeys[] = []; - -interface StorageStoreCommandArgs { - key: string; - value: any; - isWorkspace?: boolean; -} - export type StorageChangeEvent = | { /** @@ -56,7 +46,6 @@ export class Storage implements Disposable { this._onDidChange, this._onDidChangeSecrets, this.context.secrets.onDidChange(e => this._onDidChangeSecrets.fire(e)), - registerCommand('gitlens.storage.store', args => this.storeFromCommand(args), this), ); } @@ -190,18 +179,4 @@ export class Storage implements Disposable { await this.context.workspaceState.update(`${extensionPrefix}:${key}`, value); this._onDidChange.fire({ keys: [key], workspace: true }); } - - async storeFromCommand(args: StorageStoreCommandArgs): Promise { - if (args.isWorkspace) { - if (!allowedStoreCommandWorkspaceStorageKeys.includes(args.key as any)) { - return; - } - await this.storeWorkspace(args.key as any, args.value); - } else { - if (!allowedStoreCommandGlobalStorageKeys.includes(args.key as any)) { - return; - } - await this.store(args.key as any, args.value); - } - } } diff --git a/src/system/object.ts b/src/system/object.ts index fe2bf43219fb2..627dd0329b6cc 100644 --- a/src/system/object.ts +++ b/src/system/object.ts @@ -179,9 +179,7 @@ export function updateRecordValue( key: string, value: T | undefined, ): Record { - if (o == null) { - o = Object.create(null) as Record; - } + o ??= Object.create(null) as Record; if (value != null && (typeof value !== 'boolean' || value)) { if (typeof value === 'object') { diff --git a/src/system/version.ts b/src/system/version.ts index d9bc3c7ed5936..832da56213a6e 100644 --- a/src/system/version.ts +++ b/src/system/version.ts @@ -49,6 +49,12 @@ export function fromString(version: string): Version { return from(major, minor, patch, pre); } +export function fromVersion(v: Version, includePre: false): `${number}.${number}.${number}`; +export function fromVersion(v: Version, includePre?: boolean): `${number}.${number}.${number}${string | undefined}`; +export function fromVersion(v: Version, includePre?: boolean): `${number}.${number}.${number}${string | undefined}` { + return `${v.major}.${v.minor}.${v.patch}${includePre && v.pre ? `-${v.pre}` : ''}`; +} + export function satisfies( v: string | Version | null | undefined, requirement: `${'=' | '>' | '>=' | '<' | '<='} ${string}`, diff --git a/src/uris/deepLinks/deepLinkService.ts b/src/uris/deepLinks/deepLinkService.ts index cd253ca70b663..45d1b5665d2a7 100644 --- a/src/uris/deepLinks/deepLinkService.ts +++ b/src/uris/deepLinks/deepLinkService.ts @@ -19,6 +19,7 @@ import { parseGitRemoteUrl } from '../../git/parsers/remoteParser.js'; import { getBranchNameWithoutRemote } from '../../git/utils/branch.utils.js'; import { createReference } from '../../git/utils/reference.utils.js'; import { isSha } from '../../git/utils/revision.utils.js'; +import { isWalkthroughSupported } from '../../onboarding/walkthroughStateProvider.js'; import { ensureAccount } from '../../plus/gk/utils/-webview/acount.utils.js'; import { ensurePaidPlan } from '../../plus/gk/utils/-webview/plus.utils.js'; import { createQuickPickSeparator } from '../../quickpicks/items/common.js'; @@ -31,7 +32,6 @@ import { debug } from '../../system/decorators/log.js'; import { once } from '../../system/event.js'; import { Logger } from '../../system/logger.js'; import { maybeUri, normalizePath } from '../../system/path.js'; -import { isWalkthroughSupported } from '../../telemetry/walkthroughStateProvider.js'; import { showInspectView } from '../../webviews/commitDetails/actions.js'; import type { ShowWipArgs } from '../../webviews/commitDetails/protocol.js'; import type { ShowInCommitGraphCommandArgs } from '../../webviews/plus/graph/registration.js'; diff --git a/src/views/commitsView.ts b/src/views/commitsView.ts index 0937c1d0fb81b..edc9d7b7b2be0 100644 --- a/src/views/commitsView.ts +++ b/src/views/commitsView.ts @@ -13,6 +13,7 @@ import type { GitUser } from '../git/models/user.js'; import { matchContributor } from '../git/utils/contributor.utils.js'; import { getLastFetchedUpdateInterval } from '../git/utils/fetch.utils.js'; import { getReferenceLabel } from '../git/utils/reference.utils.js'; +import type { UsageChangeEvent } from '../onboarding/usageTracker.js'; import { showContributorsPicker } from '../quickpicks/contributorsPicker.js'; import { getRepositoryOrShowPicker } from '../quickpicks/repositoryPicker.js'; import { createCommand, executeCommand } from '../system/-webview/command.js'; @@ -21,7 +22,6 @@ import { setContext } from '../system/-webview/context.js'; import { gate } from '../system/decorators/gate.js'; import { debug } from '../system/decorators/log.js'; import { disposableInterval } from '../system/function.js'; -import type { UsageChangeEvent } from '../telemetry/usageTracker.js'; import { RepositoriesSubscribeableNode } from './nodes/abstract/repositoriesSubscribeableNode.js'; import { RepositoryFolderNode } from './nodes/abstract/repositoryFolderNode.js'; import type { ViewNode } from './nodes/abstract/viewNode.js'; diff --git a/src/views/views.ts b/src/views/views.ts index 60d0da8acf2b3..e5b292e5002c0 100644 --- a/src/views/views.ts +++ b/src/views/views.ts @@ -11,6 +11,7 @@ import type { } from '../git/models/reference.js'; import type { GitRemote } from '../git/models/remote.js'; import type { GitWorktree } from '../git/models/worktree.js'; +import type { OnboardingChangeEvent } from '../onboarding/onboardingService.js'; import { executeCommand, executeCoreCommand, registerCommand } from '../system/-webview/command.js'; import { configuration } from '../system/-webview/configuration.js'; import { getContext, setContext } from '../system/-webview/context.js'; @@ -108,13 +109,14 @@ export class Views implements Disposable { ) { this._disposable = Disposable.from( configuration.onDidChange(this.onConfigurationChanged, this), + this.container.onboarding.onDidChange(this.onOnboardingChanged, this), new ViewCommands(container), ...this.registerViews(), ...this.registerWebviewViews(webviews), ...this.registerCommands(), ); - this._welcomeDismissed = container.storage.get('views:scm:grouped:welcome:dismissed', false); + this._welcomeDismissed = container.onboarding.isDismissed('views:scmGrouped:welcome'); let newInstall = false; let showGitLensView = false; @@ -129,7 +131,7 @@ export class Views implements Disposable { } } } else if (!this._welcomeDismissed) { - void container.storage.store('views:scm:grouped:welcome:dismissed', true).catch(); + void container.onboarding.dismiss('views:scmGrouped:welcome').catch(); this._welcomeDismissed = true; } @@ -184,6 +186,13 @@ export class Views implements Disposable { } } + private onOnboardingChanged(e: OnboardingChangeEvent) { + if (e.key === 'views:scmGrouped:welcome') { + this._welcomeDismissed = e.dismissed; + this.updateScmGroupedViewsRegistration(); + } + } + private registerCommands(): Disposable[] { return [ registerCommand('gitlens.views.branches.attach', () => this.toggleScmViewGrouping('branches', true)), @@ -398,12 +407,12 @@ export class Views implements Disposable { registerCommand('gitlens.views.scm.grouped.welcome.dismiss', () => { this._welcomeDismissed = true; - void this.container.storage.store('views:scm:grouped:welcome:dismissed', true).catch(); + void this.container.onboarding.dismiss('views:scmGrouped:welcome').catch(); this.updateScmGroupedViewsRegistration(); }), registerCommand('gitlens.views.scm.grouped.welcome.restore', async () => { this._welcomeDismissed = true; - void this.container.storage.store('views:scm:grouped:welcome:dismissed', true).catch(); + void this.container.onboarding.dismiss('views:scmGrouped:welcome').catch(); await updateScmGroupedViewsInConfig(new Set()); }), ]; diff --git a/src/webviews/apps/rebase/rebase.css.ts b/src/webviews/apps/rebase/rebase.css.ts index d7efb18d88b21..348c4a345586b 100644 --- a/src/webviews/apps/rebase/rebase.css.ts +++ b/src/webviews/apps/rebase/rebase.css.ts @@ -167,6 +167,15 @@ export const rebaseStyles = css` --gl-banner-primary-emphasis-background: var(--vscode-inputValidation-warningBorder, #cca700); } + .close-warning-banner { + margin: 0.5rem 1rem; + + /* Info-style colors */ + --gl-banner-primary-background: var(--vscode-inputValidation-infoBackground, rgba(0, 127, 212, 0.15)); + --gl-banner-secondary-background: var(--vscode-inputValidation-infoBackground, rgba(0, 127, 212, 0.15)); + --gl-banner-text-color: var(--vscode-inputValidation-infoForeground, inherit); + } + /* ========================================================================== Header ========================================================================== */ diff --git a/src/webviews/apps/rebase/rebase.ts b/src/webviews/apps/rebase/rebase.ts index 6e8e88c0b5cb3..9f45341386bf5 100644 --- a/src/webviews/apps/rebase/rebase.ts +++ b/src/webviews/apps/rebase/rebase.ts @@ -16,6 +16,7 @@ import { ChangeEntriesCommand, ChangeEntryCommand, ContinueCommand, + DismissCloseWarningCommand, isCommandEntry, isCommitEntry, MoveEntriesCommand, @@ -104,6 +105,9 @@ export class GlRebaseEditor extends GlAppHost { /** Conflict detection stale state - set when commits are dropped */ @state() private conflictDetectionStale = false; + /** Local state for close warning dismissal (optimistic update) */ + @state() private closeWarningDismissedLocal = false; + /** Cached values computed in willUpdate for performance */ private _idToSortedIndex = new Map(); private _oldestCommitId: string | undefined; @@ -1213,7 +1217,7 @@ export class GlRebaseEditor extends GlAppHost { : !isReadOnly ? html`
No commits to rebase
` : nothing} - ${this.renderFooter()} + ${this.renderCloseWarningBanner()} ${this.renderFooter()} `; } @@ -1229,6 +1233,28 @@ export class GlRebaseEditor extends GlAppHost { >`; } + private renderCloseWarningBanner() { + // Don't show for active rebases or if dismissed + if (this.isActiveRebase || this.closeWarningDismissedLocal || this.state?.closeWarningDismissed) { + return nothing; + } + + return html``; + } + + private onDismissCloseWarning = () => { + this._ipc.sendCommand(DismissCloseWarningCommand, undefined); + // Optimistically update local state to hide banner immediately + this.closeWarningDismissedLocal = true; + }; + private renderConflictIndicator() { // Only show for new rebases (not active ones) if (this.isRebasing || !this.state?.branch || !this.state?.onto) { diff --git a/src/webviews/apps/shared/components/mcp-banner.ts b/src/webviews/apps/shared/components/mcp-banner.ts index c0a4c12007f3b..f2d0b80ec9877 100644 --- a/src/webviews/apps/shared/components/mcp-banner.ts +++ b/src/webviews/apps/shared/components/mcp-banner.ts @@ -62,9 +62,8 @@ export class GlMcpBanner extends LitElement { banner-title="GitKraken MCP Bundled with GitLens" body="${bodyHtml}" dismissible - dismiss-href="${createCommandLink('gitlens.storage.store', { - key: 'mcp:banner:dismissed', - value: true, + dismiss-href="${createCommandLink('gitlens.onboarding.dismiss', { + id: 'mcp:banner', })}" > `; @@ -82,9 +81,8 @@ export class GlMcpBanner extends LitElement { primary-button="Install GitKraken MCP" primary-button-href="${createCommandLink('gitlens.ai.mcp.install', { source: this.source })}" dismissible - dismiss-href="${createCommandLink('gitlens.storage.store', { - key: 'mcp:banner:dismissed', - value: true, + dismiss-href="${createCommandLink('gitlens.onboarding.dismiss', { + id: 'mcp:banner', })}" > `; diff --git a/src/webviews/home/homeWebview.ts b/src/webviews/home/homeWebview.ts index ca67f1a26cb41..683a2853580c0 100644 --- a/src/webviews/home/homeWebview.ts +++ b/src/webviews/home/homeWebview.ts @@ -54,6 +54,7 @@ import { getBranchNameWithoutRemote } from '../../git/utils/branch.utils.js'; import { getComparisonRefsForPullRequest } from '../../git/utils/pullRequest.utils.js'; import { createRevisionRange } from '../../git/utils/revision.utils.js'; import { showGitErrorMessage } from '../../messages.js'; +import type { OnboardingChangeEvent } from '../../onboarding/onboardingService.js'; import type { AIModelChangeEvent } from '../../plus/ai/aiProviderService.js'; import { showPatchesView } from '../../plus/drafts/actions.js'; import type { Subscription } from '../../plus/gk/models/subscription.js'; @@ -81,7 +82,6 @@ import { } from '../../system/-webview/command.js'; import { configuration } from '../../system/-webview/configuration.js'; import { getContext, onDidChangeContext } from '../../system/-webview/context.js'; -import type { StorageChangeEvent } from '../../system/-webview/storage.js'; import { openUrl } from '../../system/-webview/vscode/uris.js'; import { openWorkspace } from '../../system/-webview/vscode/workspaces.js'; import { createCommandDecorator, getWebviewCommand } from '../../system/decorators/command.js'; @@ -89,7 +89,7 @@ import { debug, log } from '../../system/decorators/log.js'; import type { Deferrable } from '../../system/function/debounce.js'; import { debounce } from '../../system/function/debounce.js'; import { filterMap } from '../../system/iterable.js'; -import { getLoggableName, Logger } from '../../system/logger.js'; +import { Logger, getLoggableName } from '../../system/logger.js'; import { startLogScope } from '../../system/logger.scope.js'; import { hasKeys } from '../../system/object.js'; import { getSettledValue } from '../../system/promise.js'; @@ -203,7 +203,7 @@ export class HomeWebviewProvider implements WebviewProvider { @@ -819,8 +819,8 @@ export class GraphWebviewProvider implements WebviewProvider { /** Subscription state for Pro feature gating */ subscription?: Subscription; + + /** Whether the close-warning banner has been dismissed */ + closeWarningDismissed?: boolean; } /** Reason the rebase is paused */ @@ -179,6 +182,8 @@ export const GetMissingCommitsCommand = new IpcCommand( export const RecomposeCommand = new IpcCommand(scope, 'recompose/open'); +export const DismissCloseWarningCommand = new IpcCommand(scope, 'closeWarning/dismiss'); + // REQUESTS export interface GetPotentialConflictsParams { diff --git a/src/webviews/rebase/rebaseWebviewProvider.ts b/src/webviews/rebase/rebaseWebviewProvider.ts index a7ab122b1ef90..91e57c6263310 100644 --- a/src/webviews/rebase/rebaseWebviewProvider.ts +++ b/src/webviews/rebase/rebaseWebviewProvider.ts @@ -17,6 +17,7 @@ import { RepositoryChange, RepositoryChangeComparisonMode } from '../../git/mode import { processRebaseEntries, readAndParseRebaseDoneFile } from '../../git/utils/-webview/rebase.parsing.utils.js'; import { reopenRebaseTodoEditor } from '../../git/utils/-webview/rebase.utils.js'; import { createReference } from '../../git/utils/reference.utils.js'; +import type { OnboardingChangeEvent } from '../../onboarding/onboardingService.js'; import type { Subscription } from '../../plus/gk/models/subscription.js'; import { isSubscriptionTrialOrPaidFromState } from '../../plus/gk/utils/subscription.utils.js'; import { executeCommand, executeCoreCommand } from '../../system/-webview/command.js'; @@ -53,6 +54,7 @@ import { DidChangeCommitsNotification, DidChangeNotification, DidChangeSubscriptionNotification, + DismissCloseWarningCommand, GetMissingAvatarsCommand, GetMissingCommitsCommand, GetPotentialConflictsRequest, @@ -151,6 +153,7 @@ export class RebaseWebviewProvider implements Disposable { this.container.subscription.onDidChange(e => { this.onSubscriptionChanged(e.current); }), + this.container.onboarding.onDidChange(this.onOnboardingChanged, this), ); // Subscribe to repository changes @@ -230,6 +233,12 @@ export class RebaseWebviewProvider implements Disposable { } } + private onOnboardingChanged(e: OnboardingChangeEvent): void { + if (e.key === 'rebaseEditor:closeWarning') { + this.updateState(); + } + } + private onSubscriptionChanged(subscription: Subscription): void { if (!this.host.visible) return; @@ -266,6 +275,12 @@ export class RebaseWebviewProvider implements Disposable { await continuePausedOperation(svc); } + @ipcCommand(DismissCloseWarningCommand) + @log() + private async onDismissCloseWarning(): Promise { + await this.container.onboarding.dismiss('rebaseEditor:closeWarning'); + } + @ipcCommand(RecomposeCommand) @log() private async onRecompose(): Promise { @@ -712,6 +727,7 @@ export class RebaseWebviewProvider implements Disposable { rebaseStatus: rebaseStatus, repoPath: this.repoPath, subscription: subscription, + closeWarningDismissed: this.container.onboarding.isDismissed('rebaseEditor:closeWarning'), }; }