diff --git a/package.json b/package.json index e6d3e7ec3..ccef3077b 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "url": "https://github.com/genesis-ai-dev/codex-editor" }, "license": "MIT", - "version": "0.19.0", + "version": "0.20.0", "engines": { "node": ">=18.0.0", "vscode": "^1.78.0" @@ -49,15 +49,6 @@ "contextualTitle": "Codex Project Settings" } ], - "automated-testing-view": [ - { - "type": "webview", - "id": "codex-editor.automatedTesting", - "name": "Automated Testing", - "icon": "$(beaker)", - "contextualTitle": "Automated Testing" - } - ], "search-passages-view": [ { "type": "webview", @@ -105,11 +96,6 @@ "title": "Codex Main Menu", "icon": "$(list-tree)" }, - { - "id": "automated-testing-view", - "title": "Automated Testing", - "icon": "$(beaker)" - }, { "id": "codex-files-view", "title": "Navigation", diff --git a/src/activationHelpers/contextAware/commands.ts b/src/activationHelpers/contextAware/commands.ts index ce23ecd6f..1faeccafa 100644 --- a/src/activationHelpers/contextAware/commands.ts +++ b/src/activationHelpers/contextAware/commands.ts @@ -172,7 +172,7 @@ export async function registerCommands(context: vscode.ExtensionContext) { const setEditorFontCommand = vscode.commands.registerCommand( "codex-editor-extension.setEditorFontToTargetLanguage", - await setTargetFont + setTargetFont ); const exportCodexContentCommand = vscode.commands.registerCommand( diff --git a/src/activationHelpers/contextAware/contentIndexes/indexes/index.ts b/src/activationHelpers/contextAware/contentIndexes/indexes/index.ts index 32b984eb0..97c2fccb3 100644 --- a/src/activationHelpers/contextAware/contentIndexes/indexes/index.ts +++ b/src/activationHelpers/contextAware/contentIndexes/indexes/index.ts @@ -36,7 +36,6 @@ import { readSourceAndTargetFiles } from "./fileReaders"; import { debounce } from "lodash"; import { MinimalCellResult, TranslationPair } from "../../../../../types"; import { getNotebookMetadataManager } from "../../../../utils/notebookMetadataManager"; -import { updateSplashScreenTimings } from "../../../../providers/SplashScreen/register"; import { FileSyncManager, FileSyncResult } from "../fileSyncManager"; type WordFrequencyMap = Map; @@ -859,30 +858,38 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { prompt: "Enter a query to search all cells", placeHolder: "e.g. love, faith, hope", }); - if (!query) return []; // User cancelled the input + if (!query) return []; showInfo = true; } + const searchScope = options?.searchScope || "both"; + const selectedFiles = options?.selectedFiles || []; + const completeOnly = options?.completeOnly || false; + const isParallelPassagesWebview = options?.isParallelPassagesWebview || false; + let results: TranslationPair[] = []; - // If we only want complete pairs and we have SQLite, use the more reliable method + // When includeIncomplete=false, use optimized SQLite path for complete pairs only + // When includeIncomplete=true, use searchAllCells which adds source-only cells if (!includeIncomplete && translationPairsIndex instanceof SQLiteIndexManager) { - const searchScope = options?.searchScope || "both"; - // Request more results if we need to filter by searchScope - const searchLimit = searchScope !== "both" ? k * 3 : k; - // For UI search, search source-only only when searchScope is "source" - // For "target" and "both", search both source and target (then filter for target if needed) + // Determine search mode based on scope + // searchSourceOnly=true means only search source content + // searchSourceOnly=false means search both source and target const searchSourceOnly = searchScope === "source"; + + // Request extra results to account for post-filtering + const searchLimit = k * 2; + const searchResults = await translationPairsIndex.searchCompleteTranslationPairsWithValidation( query, searchLimit, - options?.isParallelPassagesWebview || false, - false, // onlyValidated - show all complete pairs regardless of validation status + isParallelPassagesWebview, + completeOnly, // Pass through completeOnly for validation filtering searchSourceOnly ); // Convert to TranslationPair format - let translationPairs = searchResults.map((result) => ({ + let translationPairs: TranslationPair[] = searchResults.map((result) => ({ cellId: result.cellId || result.cell_id, sourceCell: { cellId: result.cellId || result.cell_id, @@ -898,25 +905,35 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { }, })); - // Apply searchScope filtering if needed - if (searchScope === "source" || searchScope === "target") { + // Filter out pairs with empty or minimal content + // Strip HTML and check for meaningful content (not just whitespace or very short text) + translationPairs = translationPairs.filter((pair) => { + const sourceText = stripHtml(pair.sourceCell.content || "").trim(); + const targetText = stripHtml(pair.targetCell.content || "").trim(); + // Require both source and target to have meaningful content (more than 3 chars) + return sourceText.length > 3 && targetText.length > 3; + }); + + // Apply searchScope content filtering - verify query actually appears in content + if (query.trim()) { const queryLower = query.toLowerCase(); translationPairs = translationPairs.filter((pair) => { + const cleanSource = stripHtml(pair.sourceCell.content || ""); + const cleanTarget = stripHtml(pair.targetCell.content || ""); + if (searchScope === "source") { - if (!pair.sourceCell.content) return false; - const cleanSource = stripHtml(pair.sourceCell.content); return cleanSource.includes(queryLower); - } else { - if (!pair.targetCell.content) return false; - const cleanTarget = stripHtml(pair.targetCell.content); + } else if (searchScope === "target") { return cleanTarget.includes(queryLower); + } else { + // "both" - query should appear in either source or target + return cleanSource.includes(queryLower) || cleanTarget.includes(queryLower); } }); } - // Apply selectedFiles filtering if needed - if (options?.selectedFiles && options.selectedFiles.length > 0) { - const selectedFiles = options.selectedFiles; + // Apply selectedFiles filtering + if (selectedFiles.length > 0) { translationPairs = translationPairs.filter((pair) => { const sourceUri = pair.sourceCell?.uri || ""; const targetUri = pair.targetCell?.uri || ""; @@ -931,14 +948,14 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { results = translationPairs.slice(0, k); } else { - // Use the original method for incomplete searches or non-SQLite indexes + // Fallback for incomplete searches or non-SQLite indexes results = await searchAllCells( translationPairsIndex, sourceTextIndex, query, k, includeIncomplete, - options // Pass through options including isParallelPassagesWebview + options ); } diff --git a/src/activationHelpers/contextAware/contentIndexes/indexes/search.ts b/src/activationHelpers/contextAware/contentIndexes/indexes/search.ts index 64f9b1799..de402f675 100644 --- a/src/activationHelpers/contextAware/contentIndexes/indexes/search.ts +++ b/src/activationHelpers/contextAware/contentIndexes/indexes/search.ts @@ -386,93 +386,50 @@ export async function searchAllCells( includeIncomplete: boolean = true, options?: any ): Promise { - const searchScope = options?.searchScope || "both"; // "both" | "source" | "target" - const selectedFiles = options?.selectedFiles || []; // Array of file URIs to filter by + const searchScope = options?.searchScope || "both"; + const selectedFiles = options?.selectedFiles || []; + const isParallelPassagesWebview = options?.isParallelPassagesWebview || false; + // Helper to check if a pair matches selected files filter function matchesSelectedFiles(pair: TranslationPair): boolean { if (!selectedFiles || selectedFiles.length === 0) return true; - + const sourceUri = pair.sourceCell?.uri || ""; const targetUri = pair.targetCell?.uri || ""; const normalizedSource = normalizeUri(sourceUri); const normalizedTarget = normalizeUri(targetUri); - + return selectedFiles.some((selectedUri: string) => { const normalizedSelected = normalizeUri(selectedUri); return normalizedSource === normalizedSelected || normalizedTarget === normalizedSelected; }); } - // Handle explicit search scope (source or target only) - if (searchScope === "source" && translationPairsIndex instanceof SQLiteIndexManager) { - // Search only source cells - const sourceCells = await translationPairsIndex.searchCells(query, "source", k * 2, options?.isParallelPassagesWebview || false); - - const results: TranslationPair[] = []; - for (const cell of sourceCells) { - const translationPair = await getTranslationPairFromProject( - translationPairsIndex, - sourceTextIndex, - cell.cell_id, - options - ); - if (translationPair && translationPair.sourceCell.content) { - // Verify the source content actually contains the query - const cleanSource = stripHtml(translationPair.sourceCell.content); - const queryLower = query.toLowerCase(); - if (cleanSource.includes(queryLower) && matchesSelectedFiles(translationPair)) { - results.push(translationPair); - } - } - } - - return results.slice(0, k); + // Helper to verify content contains query + function contentContainsQuery(content: string | undefined, queryLower: string): boolean { + if (!content) return false; + return stripHtml(content).includes(queryLower); } - // For searchScope === "target", search directly in target cells - if (searchScope === "target" && translationPairsIndex instanceof SQLiteIndexManager) { - const targetCells = await translationPairsIndex.searchCells(query, "target", k * 2, options?.isParallelPassagesWebview || false); - - const results: TranslationPair[] = []; - for (const cell of targetCells) { - const translationPair = await getTranslationPairFromProject( - translationPairsIndex, - sourceTextIndex, - cell.cell_id, - options - ); - if (translationPair && translationPair.targetCell.content) { - // Verify the target content actually contains the query - const cleanTarget = stripHtml(translationPair.targetCell.content); - const queryLower = query.toLowerCase(); - if (cleanTarget.includes(queryLower) && matchesSelectedFiles(translationPair)) { - results.push(translationPair); - } - } - } - - return results.slice(0, k); - } + const queryLower = query.toLowerCase(); + let results: TranslationPair[] = []; - // Normal search mode - search translation pairs with both source and target - // Note: searchScope is "both" here since "source" and "target" return early above - // Use the optimized SQLite method for complete pairs, then add incomplete pairs if needed - let translationPairs: TranslationPair[] = []; - if (translationPairsIndex instanceof SQLiteIndexManager) { - // Use the optimized searchCompleteTranslationPairsWithValidation method - const searchLimit = includeIncomplete ? k * 2 : k; // Request more if we need to add incomplete pairs - // For UI search, search both source and target when searchScope is "both", otherwise source-only - const searchSourceOnly = searchScope === "both" ? false : true; + // Determine search mode + const searchSourceOnly = searchScope === "source"; + const searchLimit = k * 2; + + // Search complete pairs first const searchResults = await translationPairsIndex.searchCompleteTranslationPairsWithValidation( query, searchLimit, - options?.isParallelPassagesWebview || false, - false, // onlyValidated - show all complete pairs + isParallelPassagesWebview, + false, // onlyValidated searchSourceOnly ); - - translationPairs = searchResults.map((result) => ({ + + // Convert to TranslationPair format + results = searchResults.map((result) => ({ cellId: result.cellId || result.cell_id, sourceCell: { cellId: result.cellId || result.cell_id, @@ -487,65 +444,92 @@ export async function searchAllCells( line: result.line || 0, }, })); - } - let combinedResults: TranslationPair[] = translationPairs; + // Filter out pairs with empty or minimal content (require both source and target) + results = results.filter((pair) => { + const sourceText = stripHtml(pair.sourceCell.content || "").trim(); + const targetText = stripHtml(pair.targetCell.content || "").trim(); + return sourceText.length > 3 && targetText.length > 3; + }); - if (includeIncomplete) { - // If we're including incomplete pairs, also search source-only cells - // Note: searchScope is "both" here since "source" and "target" return early above - const sourceOnlyCells = sourceTextIndex - .search(query, { - fields: ["content"], - combineWith: "OR", - prefix: true, - fuzzy: 0.2, - boost: { content: 2 }, - ...options // Pass through options including isParallelPassagesWebview - }) - .map((result: any) => ({ - cellId: result.cellId, - sourceCell: { - cellId: result.cellId, - content: result.content, - versions: result.versions, - notebookId: result.notebookId, - uri: result.uri || "", // Include URI for file filtering - }, - targetCell: { + // Apply search scope content verification - query must appear in relevant content + if (query.trim()) { + results = results.filter((pair) => { + if (searchScope === "source") { + return contentContainsQuery(pair.sourceCell.content, queryLower); + } else if (searchScope === "target") { + return contentContainsQuery(pair.targetCell.content, queryLower); + } else { + // "both" - query should appear in either source or target + return contentContainsQuery(pair.sourceCell.content, queryLower) || + contentContainsQuery(pair.targetCell.content, queryLower); + } + }); + } + + // Add incomplete pairs (source-only cells) if requested + if (includeIncomplete) { + const existingIds = new Set(results.map((r) => r.cellId)); + const sourceOnlyCells = sourceTextIndex + .search(query, { + fields: ["content"], + combineWith: "OR", + prefix: true, + fuzzy: 0.2, + boost: { content: 2 }, + }) + .filter((result: any) => { + // Skip if already in results + if (existingIds.has(result.cellId)) return false; + // Require meaningful source content + const sourceText = stripHtml(result.content || "").trim(); + if (sourceText.length <= 3) return false; + // Verify query actually appears in content + if (query.trim() && !sourceText.toLowerCase().includes(queryLower)) return false; + return true; + }) + .map((result: any) => ({ cellId: result.cellId, - content: "", - versions: [], - notebookId: "", - }, - score: result.score, - })) - .filter((pair: TranslationPair) => matchesSelectedFiles(pair)) // Filter by selected files - // Only include source-only cells that aren't already in translationPairs - .filter((sourcePair: TranslationPair) => - !translationPairs.some(tp => tp.cellId === sourcePair.cellId) - ); - - combinedResults = [...combinedResults, ...sourceOnlyCells]; + sourceCell: { + cellId: result.cellId, + content: result.content, + versions: result.versions, + notebookId: result.notebookId, + uri: result.uri || "", + }, + targetCell: { + cellId: result.cellId, + content: "", + versions: [], + notebookId: "", + }, + score: result.score, + })); + + results = [...results, ...sourceOnlyCells]; + } } - // Filter by selected files if specified (using helper function defined above) - let filteredResults = combinedResults; - if (selectedFiles && selectedFiles.length > 0) { - filteredResults = combinedResults.filter(matchesSelectedFiles); + // Apply file filtering once at the end + if (selectedFiles.length > 0) { + results = results.filter(matchesSelectedFiles); } - // Remove duplicates based on cellId - const uniqueResults = filteredResults.filter( - (v, i, a) => a.findIndex((t) => t.cellId === v.cellId) === i - ); + // Remove duplicates and sort by score + const seen = new Set(); + results = results.filter((pair) => { + if (seen.has(pair.cellId)) return false; + seen.add(pair.cellId); + return true; + }); - // Sort results by relevance (assuming higher score means more relevant) - uniqueResults.sort((a, b) => { + // Sort by score - BM25 scores are negative (more negative = better match) + // So we sort ascending to put better matches first + results.sort((a, b) => { const scoreA = "score" in a ? (a.score as number) : 0; const scoreB = "score" in b ? (b.score as number) : 0; - return scoreB - scoreA; + return scoreA - scoreB; }); - return uniqueResults.slice(0, k); + return results.slice(0, k); } \ No newline at end of file diff --git a/src/activationHelpers/contextAware/contentIndexes/indexes/sqliteIndex.ts b/src/activationHelpers/contextAware/contentIndexes/indexes/sqliteIndex.ts index 5600f9bfd..f32127aae 100644 --- a/src/activationHelpers/contextAware/contentIndexes/indexes/sqliteIndex.ts +++ b/src/activationHelpers/contextAware/contentIndexes/indexes/sqliteIndex.ts @@ -2,11 +2,16 @@ import * as vscode from "vscode"; import initSqlJs, { Database, SqlJsStatic } from "fts5-sql-bundle"; import { createHash } from "crypto"; import { TranslationPair, MinimalCellResult } from "../../../../../types"; -import { updateSplashScreenTimings } from "../../../../providers/SplashScreen/register"; -import { ActivationTiming } from "../../../../extension"; import { debounce } from "lodash"; import { EditMapUtils } from "../../../../utils/editMapUtils"; +// Progress timing interface for tracking initialization steps +interface ProgressTiming { + step: string; + duration: number; + startTime: number; +} + const INDEX_DB_PATH = [".project", "indexes.sqlite"]; const DEBUG_MODE = false; @@ -22,7 +27,7 @@ export class SQLiteIndexManager { private db: Database | null = null; private saveDebounceTimer: NodeJS.Timeout | null = null; private readonly SAVE_DEBOUNCE_MS = 0; - private progressTimings: ActivationTiming[] = []; + private progressTimings: ProgressTiming[] = []; private currentProgressTimer: NodeJS.Timeout | null = null; private currentProgressStartTime: number | null = null; private currentProgressName: string | null = null; @@ -30,7 +35,7 @@ export class SQLiteIndexManager { private trackProgress(step: string, stepStartTime: number): number { const stepEndTime = globalThis.performance.now(); - const duration = stepEndTime - stepStartTime; // Duration of THIS step only + const duration = stepEndTime - stepStartTime; this.progressTimings.push({ step, duration, startTime: stepStartTime }); debug(`${step}: ${duration.toFixed(2)}ms`); @@ -41,10 +46,7 @@ export class SQLiteIndexManager { this.currentProgressTimer = null; } - // Update splash screen with database creation progress - updateSplashScreenTimings(this.progressTimings); - - return stepEndTime; // Return the END time for the next step to use as its start time + return stepEndTime; } private startRealtimeProgress(stepName: string): number { @@ -60,7 +62,6 @@ export class SQLiteIndexManager { // Add initial timing entry this.progressTimings.push({ step: stepName, duration: 0, startTime }); - updateSplashScreenTimings(this.progressTimings); // Start real-time updates only if enabled (to avoid performance issues) if (this.enableRealtimeProgress) { @@ -72,8 +73,6 @@ export class SQLiteIndexManager { const lastIndex = this.progressTimings.length - 1; if (lastIndex >= 0 && this.progressTimings[lastIndex].step === this.currentProgressName) { this.progressTimings[lastIndex].duration = currentDuration; - // Only update splash screen every 500ms to avoid performance issues - updateSplashScreenTimings(this.progressTimings); } } }, 500) as unknown as NodeJS.Timeout; @@ -95,7 +94,6 @@ export class SQLiteIndexManager { const lastIndex = this.progressTimings.length - 1; if (lastIndex >= 0 && this.progressTimings[lastIndex].step === this.currentProgressName) { this.progressTimings[lastIndex].duration = finalDuration; - updateSplashScreenTimings(this.progressTimings); debug(`${this.currentProgressName}: ${finalDuration.toFixed(2)}ms`); } } @@ -118,7 +116,6 @@ export class SQLiteIndexManager { // Public method to add progress entries from external functions public addProgressEntry(step: string, duration: number, startTime: number): void { this.progressTimings.push({ step, duration, startTime }); - updateSplashScreenTimings(this.progressTimings); debug(`[Index] ${step}: ${duration.toFixed(2)}ms`); } @@ -1268,7 +1265,7 @@ export class SQLiteIndexManager { LEFT JOIN files s_file ON c.s_file_id = s_file.id LEFT JOIN files t_file ON c.t_file_id = t_file.id WHERE cells_fts MATCH ? - ORDER BY score DESC + ORDER BY score ASC LIMIT ? `); @@ -1735,7 +1732,7 @@ export class SQLiteIndexManager { params.push(cellType); } - sql += ` ORDER BY score DESC LIMIT ?`; + sql += ` ORDER BY score ASC LIMIT ?`; params.push(limit); const stmt = this.db.prepare(sql); @@ -1912,7 +1909,7 @@ export class SQLiteIndexManager { params.push(cellType); } - sql += ` ORDER BY score DESC LIMIT ?`; + sql += ` ORDER BY score ASC LIMIT ?`; params.push(limit); } @@ -3029,85 +3026,36 @@ export class SQLiteIndexManager { return results; } - // Debug: Check if we have any complete pairs in the database at all - const debugStmt = this.db.prepare(` - SELECT COUNT(*) as complete_pairs_count - FROM cells c - WHERE c.s_content IS NOT NULL - AND c.s_content != '' - AND c.t_content IS NOT NULL - AND c.t_content != '' - `); - - let totalCompletePairs = 0; - try { - debugStmt.step(); - totalCompletePairs = (debugStmt.getAsObject().complete_pairs_count as number) || 0; - } finally { - debugStmt.free(); - } - - // Use FTS5 with character-level n-grams (bigrams/trigrams) for fuzzy matching - // Extract words and generate character-level bigrams/trigrams from each word - const words = query - .trim() - .replace(/[^\w\s\u0370-\u03FF\u1F00-\u1FFF]/g, ' ') // Keep Greek characters and basic word chars - .replace(/\s+/g, ' ') // Normalize whitespace + // Tokenize query - keep single characters for short queries + const trimmedQuery = query.trim(); + const words = trimmedQuery + .replace(/[^\p{L}\p{N}\p{M}\s]/gu, ' ') + .replace(/\s+/g, ' ') .trim() .split(/\s+/) - .filter(token => token.length > 1); // Filter out single characters + .filter(token => token.length > 0); + // If no valid words after tokenization, return empty (don't fall back to random results) if (words.length === 0) { - return this.searchCompleteTranslationPairs('', limit, returnRawContent); + return []; } - // Generate character-level n-grams (bigrams and trigrams) from each word - const generateCharNGrams = (text: string, n: number): string[] => { - const grams: string[] = []; - if (text.length < n) return []; - for (let i = 0; i <= text.length - n; i++) { - grams.push(text.slice(i, i + n)); - } - return grams; - }; - - // Common 2-char sequences that are too generic (filter out for performance) - const commonBigrams = new Set(['of', 'to', 'in', 'on', 'at', 'he', 'we', 'is', 'it', 'an', 'or', 'as', 'be', 'by', 'if', 'my', 'no', 'so', 'up', 'us', 'th', 'er', 'ed', 'ng', 'en', 'es', 're', 'le', 'te', 'de']); - + // Generate search terms for FTS5 const searchTerms: string[] = []; for (const word of words) { - // Always add the full word (exact match is most important) + // Always add the full word searchTerms.push(word); - // For shorter words (2-4 chars), add prefix wildcard to match partial tokens like "ccc" matching "cccb" - // This helps with partial matching when FTS5 tokenizes words - if (word.length >= 2 && word.length <= 4) { - searchTerms.push(word + '*'); // Prefix wildcard for FTS5 - } - - // Generate n-grams for partial matching - // Generate trigrams for words >= 3 chars (helps match "ccc" in "cccb") - if (word.length >= 3) { - // Add character trigrams (3-char sequences) - more specific than bigrams - const trigrams = generateCharNGrams(word, 3); - searchTerms.push(...trigrams); - } - - // Generate bigrams for words >= 2 chars, but filter out common ones to reduce noise + // For words 2+ chars, add prefix wildcard for partial matching if (word.length >= 2) { - const bigrams = generateCharNGrams(word, 2); - const filteredBigrams = bigrams.filter(bg => !commonBigrams.has(bg)); - searchTerms.push(...filteredBigrams); + searchTerms.push(word + '*'); } } - // Limit total terms to avoid huge queries (keep most relevant) - // Prioritize: full words first, then trigrams, then bigrams - const maxTerms = 50; // Reasonable limit for FTS5 performance + // Build FTS5 query - use OR matching, limit terms for performance + const maxTerms = 30; const finalTerms = searchTerms.slice(0, maxTerms); - - // Use OR matching: any word or character n-gram can match, BM25 ranks by relevance - const cleanQuery = finalTerms.join(' OR '); + const cleanQuery = finalTerms.length > 0 ? finalTerms.join(' OR ') : words[0]; // Simple substring match for the original query - ensures "ccc" matches "cccb" // Escape % and _ for LIKE (SQL wildcards) @@ -3177,7 +3125,7 @@ export class SQLiteIndexManager { AND c.t_content IS NOT NULL AND c.t_content != '' ) - ORDER BY score DESC + ORDER BY score ASC LIMIT ? `; @@ -3306,67 +3254,36 @@ export class SQLiteIndexManager { return results; } - // Use FTS5 with character-level n-grams (bigrams/trigrams) for fuzzy matching - // Extract words and generate character-level bigrams/trigrams from each word - const words = query - .trim() - .replace(/[^\w\s\u0370-\u03FF\u1F00-\u1FFF]/g, ' ') // Keep Greek characters and basic word chars - .replace(/\s+/g, ' ') // Normalize whitespace + // Tokenize query - keep single characters for short queries + const trimmedQuery = query.trim(); + const words = trimmedQuery + .replace(/[^\p{L}\p{N}\p{M}\s]/gu, ' ') + .replace(/\s+/g, ' ') .trim() .split(/\s+/) - .filter(token => token.length > 1); // Filter out single characters + .filter(token => token.length > 0); + // If no valid words after tokenization, return empty (don't fall back to random results) if (words.length === 0) { - return this.searchCompleteTranslationPairsWithValidation('', limit, returnRawContent, onlyValidated, searchSourceOnly); + return []; } - // Generate character-level n-grams (bigrams and trigrams) from each word - const generateCharNGrams = (text: string, n: number): string[] => { - const grams: string[] = []; - if (text.length < n) return []; - for (let i = 0; i <= text.length - n; i++) { - grams.push(text.slice(i, i + n)); - } - return grams; - }; - - // Common 2-char sequences that are too generic (filter out for performance) - const commonBigrams = new Set(['of', 'to', 'in', 'on', 'at', 'he', 'we', 'is', 'it', 'an', 'or', 'as', 'be', 'by', 'if', 'my', 'no', 'so', 'up', 'us', 'th', 'er', 'ed', 'ng', 'en', 'es', 're', 'le', 'te', 'de']); - + // Generate search terms for FTS5 const searchTerms: string[] = []; for (const word of words) { - // Always add the full word (exact match is most important) + // Always add the full word searchTerms.push(word); - // For shorter words (2-4 chars), add prefix wildcard to match partial tokens like "ccc" matching "cccb" - // This helps with partial matching when FTS5 tokenizes words - if (word.length >= 2 && word.length <= 4) { - searchTerms.push(word + '*'); // Prefix wildcard for FTS5 - } - - // Generate n-grams for partial matching - // Generate trigrams for words >= 3 chars (helps match "ccc" in "cccb") - if (word.length >= 3) { - // Add character trigrams (3-char sequences) - more specific than bigrams - const trigrams = generateCharNGrams(word, 3); - searchTerms.push(...trigrams); - } - - // Generate bigrams for words >= 2 chars, but filter out common ones to reduce noise + // For words 2+ chars, add prefix wildcard for partial matching if (word.length >= 2) { - const bigrams = generateCharNGrams(word, 2); - const filteredBigrams = bigrams.filter(bg => !commonBigrams.has(bg)); - searchTerms.push(...filteredBigrams); + searchTerms.push(word + '*'); } } - // Limit total terms to avoid huge queries (keep most relevant) - // Prioritize: full words first, then trigrams, then bigrams - const maxTerms = 50; // Reasonable limit for FTS5 performance + // Build FTS5 query - use OR matching, limit terms for performance + const maxTerms = 30; const finalTerms = searchTerms.slice(0, maxTerms); - - // Use OR matching: any word or character n-gram can match, BM25 ranks by relevance - const cleanQuery = finalTerms.join(' OR '); + const cleanQuery = finalTerms.length > 0 ? finalTerms.join(' OR ') : words[0]; // Simple substring match for the original query - ensures "ccc" matches "cccb" // Escape % and _ for LIKE (SQL wildcards) @@ -3433,7 +3350,7 @@ export class SQLiteIndexManager { AND c.t_content IS NOT NULL AND c.t_content != '' ) - ORDER BY score DESC + ORDER BY score ASC LIMIT ? `; diff --git a/src/commands/projectSwapCommands.ts b/src/commands/projectSwapCommands.ts index 1ab30597e..db507215e 100644 --- a/src/commands/projectSwapCommands.ts +++ b/src/commands/projectSwapCommands.ts @@ -543,6 +543,19 @@ export async function initiateProjectSwap(): Promise { return; } + // Version gate: block if extensions are outdated per metadata requirements + try { + const { ensureExtensionVersionsForSwapOrUpdate } = await import("../utils/versionGate"); + const versionCheck = await ensureExtensionVersionsForSwapOrUpdate(workspacePath, { + operationLabel: "To initiate the project swap", + }); + if (!versionCheck.allowed) { + return; + } + } catch (versionErr) { + debug("Version check failed (non-fatal, allowing swap initiation):", versionErr); + } + // Check if user has permission (Project Maintainer or Owner) const permission = await checkProjectAdminPermissions(); if (!permission.hasPermission) { @@ -1150,6 +1163,20 @@ export async function initiateSwapCopy(): Promise { return; } + // Version gate: block if extensions are outdated per metadata requirements + try { + const { ensureExtensionVersionsForSwapOrUpdate } = await import("../utils/versionGate"); + const versionCheck = await ensureExtensionVersionsForSwapOrUpdate( + workspaceFolder.uri.fsPath, + { operationLabel: "To copy the project" } + ); + if (!versionCheck.allowed) { + return; + } + } catch (versionErr) { + debug("Version check failed (non-fatal, allowing copy):", versionErr); + } + // Check if user has permission (Project Maintainer or Owner) const permission = await checkProjectAdminPermissions(); if (!permission.hasPermission) { diff --git a/src/evaluation/metrics.ts b/src/evaluation/metrics.ts deleted file mode 100644 index dfdbf5a7e..000000000 --- a/src/evaluation/metrics.ts +++ /dev/null @@ -1,66 +0,0 @@ -export function stripHtml(input: string): string { - return input - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/<[^>]*>/g, '') - .trim(); -} - -function ngrams(text: string, n: number): string[] { - const grams: string[] = []; - if (text.length === 0) return grams; - for (let i = 0; i <= Math.max(0, text.length - n); i++) { - grams.push(text.slice(i, i + n)); - } - return grams.length === 0 ? [text] : grams; -} - -export function calculateCHRF(candidate: string, reference: string, maxOrder: number = 6, beta: number = 2): number { - const cand = stripHtml(candidate); - const ref = stripHtml(reference); - if (!cand || !ref) return 0; - - let sumPrecision = 0; - let sumRecall = 0; - let orders = 0; - - for (let n = 1; n <= maxOrder; n++) { - const cgrams = ngrams(cand, n); - const rgrams = ngrams(ref, n); - - const rCounts = new Map(); - for (const g of rgrams) rCounts.set(g, (rCounts.get(g) || 0) + 1); - - let overlap = 0; - const cCounts = new Map(); - for (const g of cgrams) cCounts.set(g, (cCounts.get(g) || 0) + 1); - for (const [g, cnt] of cCounts) { - overlap += Math.min(cnt, rCounts.get(g) || 0); - } - - const precision = overlap / cgrams.length; - const recall = overlap / rgrams.length; - sumPrecision += precision; - sumRecall += recall; - orders++; - } - - const avgP = sumPrecision / orders; - const avgR = sumRecall / orders; - if (avgP === 0 && avgR === 0) return 0; - const beta2 = beta * beta; - const fScore = (1 + beta2) * (avgP * avgR) / (beta2 * avgP + avgR); - return fScore; -} - -export type BatchCHRFResult = { - cellId: string; - chrf: number; - generated: string; - reference: string; -}; - - diff --git a/src/evaluation/testingCommands.ts b/src/evaluation/testingCommands.ts deleted file mode 100644 index 18aa1f594..000000000 --- a/src/evaluation/testingCommands.ts +++ /dev/null @@ -1,233 +0,0 @@ -import * as vscode from "vscode"; -import { GlobalProvider } from "../globalProvider"; -import { runAutomatedTest, selectRandomCells, TestSummary } from "./testingSuite"; - -type SnapshotSections = "codex-editor-extension" | "codex-project-manager" | string; - -async function ensureDir(uri: vscode.Uri) { - try { - await vscode.workspace.fs.stat(uri); - } catch { - await vscode.workspace.fs.createDirectory(uri); - } -} - -async function writeJson(uri: vscode.Uri, data: unknown) { - const enc = new TextEncoder(); - await vscode.workspace.fs.writeFile(uri, enc.encode(JSON.stringify(data, null, 2))); -} - -async function readJson(uri: vscode.Uri): Promise { - try { - const buf = await vscode.workspace.fs.readFile(uri); - const dec = new TextDecoder(); - return JSON.parse(dec.decode(buf)) as T; - } catch { - return null; - } -} - -function pickSettings(section: SnapshotSections, keys: string[]) { - const conf = vscode.workspace.getConfiguration(section); - const out: Record = {}; - for (const key of keys) { - out[key] = conf.get(key); - } - return out; -} - -function mapSourceUriToTargetUri(sourceUriStr: string): vscode.Uri | null { - try { - const ws = vscode.workspace.workspaceFolders?.[0]; - if (!ws) return null; - const parsed = vscode.Uri.parse(sourceUriStr); - const fileName = parsed.path.split("/").pop() || ""; - if (!fileName.toLowerCase().endsWith(".source")) return null; - const targetFile = fileName.replace(/\.source$/i, ".codex"); - return vscode.Uri.joinPath(ws.uri, "files", "target", targetFile); - } catch { - return null; - } -} - -export function registerTestingCommands(context: vscode.ExtensionContext) { - console.log('[testingCommands] Registering testing commands...'); - const snapshotCmd = vscode.commands.registerCommand("codex-testing.snapshotConfig", async () => { - const ws = vscode.workspace.workspaceFolders?.[0]; - if (!ws) { - vscode.window.showErrorMessage("Open a workspace to snapshot settings."); - return; - } - - const baseDir = vscode.Uri.joinPath(ws.uri, ".codex", "automated-tests"); - const configsDir = vscode.Uri.joinPath(baseDir, "configurations"); - await ensureDir(baseDir); - await ensureDir(configsDir); - - const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - const file = vscode.Uri.joinPath(configsDir, `config-${timestamp}.json`); - - const editorKeys = [ - "llmEndpoint", - "api_key", - "model", - "contextSize", - "additionalResourcesDirectory", - "experimentalContextOmission", - "sourceBookWhitelist", - "temperature", - "main_chat_language", - "chatSystemMessage", - "numberOfFewShotExamples", - "debugMode", - "useOnlyValidatedExamples", - "allowHtmlPredictions", - ]; - - const projectKeys = [ - "sourceLanguage", - "targetLanguage", - "validationCount", - "spellcheckIsEnabled", - "projectName", - "abbreviation", - "userName", - "userEmail", - "watchedFolders", - "projectHistory", - ]; - - const snapshot = { - createdAt: timestamp, - sections: { - "codex-editor-extension": pickSettings("codex-editor-extension", editorKeys), - "codex-project-manager": pickSettings("codex-project-manager", projectKeys), - }, - }; - - await writeJson(file, snapshot); - vscode.window.showInformationMessage(`Saved configuration snapshot: ${file.fsPath}`); - return file.fsPath; - }); - - // Main test command - unified flow for single or batch - const runTestCmd = vscode.commands.registerCommand( - "codex-testing.runTest", - async (args?: { cellIds?: string[]; count?: number; onlyValidated?: boolean; }) => { - let cellIds = args?.cellIds || []; - const count = args?.count || 10; - const onlyValidated = args?.onlyValidated || false; - - // If no specific cells provided, select random ones - if (cellIds.length === 0) { - cellIds = await selectRandomCells(count, onlyValidated); - if (cellIds.length === 0) { - vscode.window.showErrorMessage("No cells found matching criteria."); - return null; - } - } - - // Run the test with progress - return await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: `Running translation test on ${cellIds.length} cells...`, - cancellable: false - }, - async (progress) => { - const result = await runAutomatedTest(cellIds, (message, percent) => { - progress.report({ message, increment: percent }); - }); - vscode.window.showInformationMessage( - `Test complete! Average CHRF: ${result.averageCHRF.toFixed(3)} (${result.cellCount} cells)` - ); - return result; - } - ); - } - ); - - // List historical tests - const listHistoryCmd = vscode.commands.registerCommand("codex-testing.getTestHistory", async () => { - const ws = vscode.workspace.workspaceFolders?.[0]; - if (!ws) return []; - const baseDir = vscode.Uri.joinPath(ws.uri, ".codex", "automated-tests"); - try { - const entries = await vscode.workspace.fs.readDirectory(baseDir); - const files = entries - .filter(([name, type]) => type === vscode.FileType.File && /^test-.*\.json$/i.test(name)) - .map(([name]) => vscode.Uri.joinPath(baseDir, name)); - const items: Array<{ path: string; testId: string; timestamp: string; averageCHRF: number; cellCount: number; }>= []; - for (const file of files) { - const data = await readJson(file); - if (data) items.push({ path: file.fsPath, testId: data.testId, timestamp: data.timestamp, averageCHRF: data.averageCHRF, cellCount: data.cellCount }); - } - // Sort by timestamp desc - items.sort((a, b) => (new Date(b.timestamp).getTime()) - (new Date(a.timestamp).getTime())); - return items; - } catch { - return []; - } - }); - - // Load a specific test file - const loadTestCmd = vscode.commands.registerCommand("codex-testing.loadTest", async (pathOrUri: string | vscode.Uri) => { - try { - const uri = typeof pathOrUri === "string" ? vscode.Uri.file(pathOrUri) : pathOrUri; - const data = await readJson(uri); - return data; - } catch { - return null; - } - }); - - // Reapply config from a test summary's snapshot path - const reapplyCmd = vscode.commands.registerCommand("codex-testing.reapplyConfigForTest", async (pathOrUri: string | vscode.Uri) => { - const summary = await vscode.commands.executeCommand("codex-testing.loadTest", pathOrUri); - if (!summary) { - vscode.window.showErrorMessage("Failed to load test summary."); - return false; - } - // summary.configSnapshot is a path string to a config JSON - const snapshotPath = typeof summary.configSnapshot === "string" ? summary.configSnapshot : ""; - if (!snapshotPath) { - vscode.window.showErrorMessage("No configuration snapshot found in test summary."); - return false; - } - try { - const snapshot = await readJson<{ createdAt: string; sections: Record>; }>(vscode.Uri.file(snapshotPath)); - if (!snapshot) throw new Error("Snapshot file not found"); - // Apply settings - for (const [section, kv] of Object.entries(snapshot.sections || {})) { - const config = vscode.workspace.getConfiguration(section); - for (const [key, value] of Object.entries(kv)) { - await config.update(key, value, vscode.ConfigurationTarget.Workspace); - } - } - vscode.window.showInformationMessage("Configuration reapplied from snapshot."); - return true; - } catch (e) { - vscode.window.showErrorMessage(`Failed to reapply configuration: ${e instanceof Error ? e.message : String(e)}`); - return false; - } - }); - - // Delete a test file - const deleteTestCmd = vscode.commands.registerCommand("codex-testing.deleteTest", async (pathOrUri: string | vscode.Uri) => { - console.log('[testingCommands] deleteTest command called with:', pathOrUri); - try { - const uri = typeof pathOrUri === "string" ? vscode.Uri.file(pathOrUri) : pathOrUri; - console.log('[testingCommands] Attempting to delete file at URI:', uri.toString()); - await vscode.workspace.fs.delete(uri); - console.log('[testingCommands] File deleted successfully'); - return true; - } catch (e) { - console.error('[testingCommands] Failed to delete test file:', e); - return false; - } - }); - - context.subscriptions.push(snapshotCmd, runTestCmd, listHistoryCmd, loadTestCmd, reapplyCmd, deleteTestCmd); - console.log('[testingCommands] All testing commands registered successfully, including deleteTest'); -} - diff --git a/src/evaluation/testingSuite.ts b/src/evaluation/testingSuite.ts deleted file mode 100644 index 847881193..000000000 --- a/src/evaluation/testingSuite.ts +++ /dev/null @@ -1,182 +0,0 @@ -import * as vscode from "vscode"; -import { calculateCHRF } from "./metrics"; -import { fetchCompletionConfig } from "../utils/llmUtils"; -import { CodexNotebookReader } from "../serializer"; -import { llmCompletion } from "../providers/translationSuggestions/llmCompletion"; -import { getSQLiteIndexManager } from "../activationHelpers/contextAware/contentIndexes/indexes/sqliteIndexManager"; - -export interface TestResult { - cellId: string; - sourceContent: string; - referenceTranslation: string; - generatedTranslation: string; - chrfScore: number; - timestamp: string; -} - -export interface TestSummary { - testId: string; - timestamp: string; - cellCount: number; - averageCHRF: number; - configSnapshot: any; - results: TestResult[]; -} - -async function ensureDir(uri: vscode.Uri) { - try { - await vscode.workspace.fs.stat(uri); - } catch { - await vscode.workspace.fs.createDirectory(uri); - } -} - -async function writeJson(uri: vscode.Uri, data: unknown) { - const enc = new TextEncoder(); - await vscode.workspace.fs.writeFile(uri, enc.encode(JSON.stringify(data, null, 2))); -} - -function mapSourceUriToTargetUri(sourceUriStr: string): vscode.Uri | null { - try { - const ws = vscode.workspace.workspaceFolders?.[0]; - if (!ws) return null; - const parsed = vscode.Uri.parse(sourceUriStr); - const fileName = parsed.path.split("/").pop() || ""; - if (!fileName.toLowerCase().endsWith(".source")) return null; - const targetFile = fileName.replace(/\.source$/i, ".codex"); - return vscode.Uri.joinPath(ws.uri, "files", "target", targetFile); - } catch { - return null; - } -} - -async function generateTranslationNonDestructive(cellId: string, targetUri: vscode.Uri): Promise { - try { - const completionConfig = await fetchCompletionConfig(); - const notebookReader = new CodexNotebookReader(targetUri); - const cts = new vscode.CancellationTokenSource(); - const result = await llmCompletion(notebookReader, cellId, completionConfig, cts.token, false); - const variants = (result as any)?.variants || []; - return Array.isArray(variants) && variants.length > 0 ? variants[0] : ""; - } catch (error) { - console.error(`Failed to generate translation for ${cellId}:`, error); - return ""; - } -} - -export async function selectRandomCells(count: number, onlyValidated: boolean): Promise { - try { - const indexManager = getSQLiteIndexManager(); - if (!indexManager) { - throw new Error("Index manager not available"); - } - - const results = await indexManager.searchCompleteTranslationPairsWithValidation( - "", // empty query gets all - Math.max(count * 5, 100), // get more to shuffle from - false, // don't return raw content - onlyValidated - ); - - if (!Array.isArray(results) || results.length === 0) { - return []; - } - - // Shuffle and take requested count - const shuffled = [...results].sort(() => 0.5 - Math.random()); - return shuffled.slice(0, count).map((r: any) => r.cellId || r.cell_id).filter(Boolean); - } catch (error) { - console.error("Error selecting random cells:", error); - return []; - } -} - -export async function runAutomatedTest( - cellIds: string[], - progressCallback?: (message: string, progress: number) => void -): Promise { - const testId = `test-${Date.now()}`; - const timestamp = new Date().toISOString(); - const ws = vscode.workspace.workspaceFolders?.[0]; - if (!ws) throw new Error("No workspace available"); - - progressCallback?.("Saving configuration snapshot...", 10); - - // Auto-snapshot config; store snapshot path string - const configSnapshot = await vscode.commands.executeCommand("codex-testing.snapshotConfig"); - - const results: TestResult[] = []; - const total = cellIds.length; - - for (let i = 0; i < total; i++) { - const cellId = cellIds[i]; - const progress = 20 + ((i / total) * 70); // 20-90% for processing - progressCallback?.(`Processing ${cellId} (${i + 1}/${total})...`, progress); - - try { - // Get translation pair - const pair: any = await vscode.commands.executeCommand( - "codex-editor-extension.getTranslationPairFromProject", - cellId, - undefined, - false - ); - - if (!pair?.sourceCell?.uri || !pair?.targetCell?.content) { - console.warn(`Skipping ${cellId}: incomplete translation pair`); - continue; - } - - const sourceUriStr = pair.sourceCell.uri; - const targetUri = mapSourceUriToTargetUri(sourceUriStr); - if (!targetUri) { - console.warn(`Skipping ${cellId}: failed to map to target file`); - continue; - } - - // Generate translation non-destructively - const generatedTranslation = await generateTranslationNonDestructive(cellId, targetUri); - const referenceTranslation = pair.targetCell.content || ""; - const sourceContent = pair.sourceCell.content || ""; - - // Calculate CHRF - const chrfScore = calculateCHRF(generatedTranslation, referenceTranslation); - - results.push({ - cellId, - sourceContent, - referenceTranslation, - generatedTranslation, - chrfScore, - timestamp: new Date().toISOString() - }); - - } catch (error) { - console.error(`Error processing ${cellId}:`, error); - } - } - - progressCallback?.("Calculating summary...", 95); - - const averageCHRF = results.length > 0 ? - results.reduce((sum, r) => sum + r.chrfScore, 0) / results.length : 0; - - const summary: TestSummary = { - testId, - timestamp, - cellCount: results.length, - averageCHRF, - configSnapshot, - results - }; - - // Save results - const baseDir = vscode.Uri.joinPath(ws.uri, ".codex", "automated-tests"); - await ensureDir(baseDir); - const resultsFile = vscode.Uri.joinPath(baseDir, `${testId}.json`); - await writeJson(resultsFile, summary); - - progressCallback?.("Test complete!", 100); - - return summary; -} diff --git a/src/extension.ts b/src/extension.ts index 41a25ab8c..52e55b4bb 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -12,8 +12,6 @@ import { registerProjectManager } from "./projectManager"; import { temporaryMigrationScript_checkMatthewNotebook, migration_changeDraftFolderToFilesFolder, - migration_chatSystemMessageSetting, - migration_chatSystemMessageToMetadata, migration_lineNumbersSettings, migration_editHistoryFormat, migration_addMilestoneCells, @@ -43,21 +41,12 @@ import { } from "./providers/WelcomeView/register"; import { SyncManager } from "./projectManager/syncManager"; import { MetadataManager, registerMetadataCommands } from "./utils/metadataManager"; -import { - registerSplashScreenProvider, - showSplashScreen, - updateSplashScreenTimings, - updateSplashScreenSync, - closeSplashScreen, -} from "./providers/SplashScreen/register"; import { openCellLabelImporter } from "./cellLabelImporter/cellLabelImporter"; import { openCodexMigrationTool } from "./codexMigrationTool/codexMigrationTool"; import { CodexCellEditorProvider } from "./providers/codexCellEditorProvider/codexCellEditorProvider"; import { checkForUpdatesOnStartup, registerUpdateCommands } from "./utils/updateChecker"; -import { fileExists } from "./utils/webviewUtils"; import { checkIfMetadataAndGitIsInitialized } from "./projectManager/utils/projectUtils"; import { CommentsMigrator } from "./utils/commentsMigrationUtils"; -import { registerTestingCommands } from "./evaluation/testingCommands"; import { initializeABTesting } from "./utils/abTestingSetup"; import { migration_addValidationsForUserEdits, @@ -68,106 +57,17 @@ import { } from "./projectManager/utils/migrationUtils"; import { initializeAudioProcessor } from "./utils/audioProcessor"; import { initializeAudioMerger } from "./utils/audioMerger"; -// markUserAsUpdatedInRemoteList is now called in performProjectUpdate before window reload -import * as fs from "fs"; +import { createStartupStatusBar, StartupStatusBar } from "./utils/startupStatusBar"; import * as os from "os"; import * as path from "path"; const DEBUG_MODE = false; -function debug(...args: any[]): void { +function debug(...args: unknown[]): void { if (DEBUG_MODE) { console.log("[Extension]", ...args); } } -export interface ActivationTiming { - step: string; - duration: number; - startTime: number; -} - -const activationTimings: ActivationTiming[] = []; -let currentStepTimer: NodeJS.Timeout | null = null; -let currentStepStartTime: number | null = null; -let currentStepName: string | null = null; -let lastStepEndTime: number | null = null; - -function trackTiming(step: string, stepStartTime: number): number { - const stepEndTime = globalThis.performance.now(); - const duration = stepEndTime - stepStartTime; // Duration of THIS step only - - activationTimings.push({ step, duration, startTime: stepStartTime }); - debug(`[Activation] ${step}: ${duration.toFixed(2)}ms`); - - // Stop any previous real-time timer - if (currentStepTimer) { - clearInterval(currentStepTimer); - currentStepTimer = null; - } - - // Update splash screen with latest timing information - updateSplashScreenTimings(activationTimings); - - lastStepEndTime = stepEndTime; - return stepEndTime; // Return the END time for the next step to use as its start time -} - -function startRealtimeStep(stepName: string): number { - const startTime = globalThis.performance.now(); - - // Stop any previous timer - if (currentStepTimer) { - clearInterval(currentStepTimer); - } - - currentStepName = stepName; - currentStepStartTime = startTime; - - // Add initial timing entry - activationTimings.push({ step: stepName, duration: 0, startTime }); - updateSplashScreenTimings(activationTimings); - - // Start real-time updates every 100ms - currentStepTimer = setInterval(() => { - if (currentStepStartTime && currentStepName) { - const currentDuration = globalThis.performance.now() - currentStepStartTime; - - // Update the last timing entry with current duration - const lastIndex = activationTimings.length - 1; - if (lastIndex >= 0 && activationTimings[lastIndex].step === currentStepName) { - activationTimings[lastIndex].duration = currentDuration; - updateSplashScreenTimings(activationTimings); - } - } - }, 100) as unknown as NodeJS.Timeout; - - return startTime; -} - -function finishRealtimeStep(): number { - if (currentStepTimer) { - clearInterval(currentStepTimer); - currentStepTimer = null; - } - - if (currentStepStartTime && currentStepName) { - const finalDuration = globalThis.performance.now() - currentStepStartTime; - - // Update the last timing entry with final duration - const lastIndex = activationTimings.length - 1; - if (lastIndex >= 0 && activationTimings[lastIndex].step === currentStepName) { - activationTimings[lastIndex].duration = finalDuration; - updateSplashScreenTimings(activationTimings); - debug(`[Activation] ${currentStepName}: ${finalDuration.toFixed(2)}ms`); - } - } - - currentStepName = null; - currentStepStartTime = null; - - return globalThis.performance.now(); -} - declare global { // eslint-disable-next-line var db: Database | undefined; @@ -176,137 +76,30 @@ declare global { let client: LanguageClient | undefined; let clientCommandsDisposable: vscode.Disposable; let autoCompleteStatusBarItem: StatusBarItem; -// let commitTimeout: any; -// const COMMIT_DELAY = 5000; // Delay in milliseconds let notebookMetadataManager: NotebookMetadataManager; let authApi: FrontierAPI | undefined; -let savedTabLayout: any[] = []; -const TAB_LAYOUT_KEY = "codexEditor.tabLayout"; - -// Helper to save tab layout and persist to globalState -async function saveTabLayout(context: vscode.ExtensionContext) { - const layout = vscode.window.tabGroups.all.map((group, groupIndex) => ({ - isActive: group.isActive, - tabs: group.tabs.map((tab) => { - // Try to get URI and viewType for all tab types - let uri: string | undefined = undefined; - let viewType: string | undefined = undefined; - if ((tab as any).input) { - uri = - (tab as any).input?.uri?.toString?.() || - (tab as any).input?.resource?.toString?.(); - viewType = (tab as any).input?.viewType; - } - return { - label: tab.label, - uri, - viewType, - isActive: tab.isActive, - isPinned: tab.isPinned, - groupIndex, - }; - }), - })); - savedTabLayout = layout; - await context.globalState.update(TAB_LAYOUT_KEY, layout); -} +let authInitPromise: Promise | null = null; +let authInitComplete = false; -// Helper to restore tab layout from globalState -async function restoreTabLayout(context: vscode.ExtensionContext) { - const layout = context.globalState.get(TAB_LAYOUT_KEY) || []; - // Collect tabs: open non-codex editors first, then codex editors sequentially - const nonCodexOps: Array<() => Promise> = []; - const codexTabs: Array<{ uri: string; groupIndex: number; viewType: string; }> = []; - - for (const group of layout) { - for (const tab of group.tabs) { - if (!tab.uri) continue; - const uriStr = tab.uri as string; - const viewType = tab.viewType as string | undefined; - const groupIndex = tab.groupIndex as number; - - if (viewType === "codex.cellEditor") { - codexTabs.push({ uri: uriStr, groupIndex, viewType }); - } else { - nonCodexOps.push(async () => { - try { - const uri = vscode.Uri.parse(uriStr); - // Check if file exists before trying to open - if (!(await fileExists(uri))) { - return; // Skip missing files - } - - if (viewType && viewType !== "default") { - await vscode.commands.executeCommand( - "vscode.openWith", - uri, - viewType, - { viewColumn: groupIndex + 1 } - ); - } else { - const doc = await vscode.workspace.openTextDocument(uri); - await vscode.window.showTextDocument(doc, groupIndex + 1); - } - } catch { - // Ignore missing files - } - }); - } - } - } - - // Open all non-codex editors in parallel - for (const op of nonCodexOps) { - await op(); - } +// Flag to prevent welcome view from showing during startup +let isStartupInProgress = true; - // Sort codex tabs so .source files open before .codex for the same basename - codexTabs.sort((a, b) => { - const aPath = a.uri.toLowerCase(); - const bPath = b.uri.toLowerCase(); - const aIsSource = aPath.endsWith(".source"); - const bIsSource = bPath.endsWith(".source"); - if (aIsSource !== bIsSource) return aIsSource ? -1 : 1; - return aPath.localeCompare(bPath); - }); - - // Sequentially open Codex editors and wait for readiness - const provider = CodexCellEditorProvider.getInstance(); - for (const tab of codexTabs) { - try { - const uri = vscode.Uri.parse(tab.uri); - - // Check if file exists before trying to open - if (!(await fileExists(uri))) { - continue; // Skip missing files - } - - await vscode.commands.executeCommand( - "vscode.openWith", - uri, - tab.viewType, - { viewColumn: tab.groupIndex + 1 } - ); +export function isStartingUp(): boolean { + return isStartupInProgress; +} - if (provider) { - // Wait for the specific webview to be ready (with timeout) - await provider.waitForWebviewReady(tab.uri, 4000); - } - } catch { - // Ignore missing files - } - // Yield to allow controllerchange to settle between openings - await new Promise((r) => setTimeout(r, 10)); - } - // Optionally, focus the previously active tab/group - // Clear the saved layout after restore - await context.globalState.update(TAB_LAYOUT_KEY, undefined); +export function setStartupComplete(): void { + isStartupInProgress = false; } -export async function activate(context: vscode.ExtensionContext) { - const activationStart = globalThis.performance.now(); +export async function activate(context: vscode.ExtensionContext): Promise { + const activationStart = performance.now(); - // Ensure OS temp directory exists in test/web environments (mock FS may not have /tmp) + // 1. Create status bar indicator (replaces splash screen) + const statusBar = createStartupStatusBar(context); + statusBar.show("Initializing..."); + + // Ensure OS temp directory exists in test/web environments try { const tmp = os.tmpdir(); const tmpUri = vscode.Uri.file(tmp); @@ -315,108 +108,36 @@ export async function activate(context: vscode.ExtensionContext) { console.warn("[Extension] Could not ensure temp directory exists:", e); } - // Save tab layout and close all editors before showing splash screen - try { - await saveTabLayout(context); - await vscode.commands.executeCommand("workbench.action.closeAllEditors"); - } catch (e) { - console.error("Error saving/closing tabs before splash screen:", e); - } - - // Initialize audio processor for on-demand FFmpeg downloads + // Initialize audio processors (lightweight, synchronous setup) initializeAudioProcessor(context); - // Initialize audio merger for merging audio files initializeAudioMerger(context); - // Register and show splash screen immediately before anything else + // CRITICAL: Initialize NotebookMetadataManager BEFORE providers (they depend on it) try { - // Register splash screen as the very first action - const splashStart = activationStart; - registerSplashScreenProvider(context); - showSplashScreen(activationStart); - trackTiming("Initializing Splash Screen", splashStart); + notebookMetadataManager = NotebookMetadataManager.getInstance(context); + await notebookMetadataManager.initialize(); } catch (error) { - console.error("Error showing splash screen:", error); - // Continue with activation even if splash screen fails + console.error("[Extension] Error initializing NotebookMetadataManager:", error); } - let stepStart = activationStart; - + // 2. Register ALL providers immediately (synchronous/fast operations) try { - // Configure editor layout - const layoutStart = globalThis.performance.now(); - // Use maximizeEditorHideSidebar directly to create a clean, focused editor experience on startup - // note: there may be no active editor yet, so we need to see if the welcome view is needed initially - await vscode.commands.executeCommand("workbench.action.maximizeEditorHideSidebar"); - stepStart = trackTiming("Configuring Editor Layout", layoutStart); - - // Setup pre-activation commands - const preCommandsStart = globalThis.performance.now(); - await executeCommandsBefore(context); - stepStart = trackTiming("Setting up Pre-activation Commands", preCommandsStart); - - // Initialize metadata manager - const metadataStart = globalThis.performance.now(); - notebookMetadataManager = NotebookMetadataManager.getInstance(context); - await notebookMetadataManager.initialize(); - stepStart = trackTiming("Loading Project Metadata", metadataStart); - - // Migrate comments early during project startup - const migrationStart = globalThis.performance.now(); - if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) { - try { - await CommentsMigrator.migrateProjectComments(vscode.workspace.workspaceFolders[0].uri); + // Register project manager and welcome view first + await registerProjectManager(context); + registerWelcomeViewProvider(context); - // Also repair any existing corrupted data during startup - const commentsFilePath = vscode.Uri.joinPath(vscode.workspace.workspaceFolders[0].uri, ".project", "comments.json"); - CommentsMigrator.repairExistingCommentsFile(commentsFilePath, true).catch(() => { - // Silent fallback - don't block startup if repair fails - }); - } catch (error) { - console.error("[Extension] Error during startup comments migration:", error); - // Don't fail startup due to migration errors - } - - } - stepStart = trackTiming("Migrating Legacy Comments", migrationStart); - - // Initialize Frontier API first - needed before startup flow - const authStart = globalThis.performance.now(); - const extension = await waitForExtensionActivation("frontier-rnd.frontier-authentication"); - if (extension?.isActive) { - authApi = extension.exports; - } - stepStart = trackTiming("Connecting Authentication Service", authStart); - - // Update git configuration files after Frontier auth is connected - // This ensures .gitignore and .gitattributes are current when extension starts - const gitConfigStart = globalThis.performance.now(); - if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) { - try { - // Import and run git config update (only if we have a workspace) - const { ensureGitConfigsAreUpToDate } = await import("./projectManager/utils/projectUtils"); - await ensureGitConfigsAreUpToDate(); - console.log("[Extension] Git configuration files updated on startup"); - } catch (error) { - console.error("[Extension] Error updating git config files on startup:", error); - // Don't fail startup due to git config update errors - } - - } - stepStart = trackTiming("Updating Git Configuration", gitConfigStart); - - // Run independent initialization steps in parallel (excluding auth which is needed by startup flow) - const parallelInitStart = globalThis.performance.now(); + // Register all other providers await Promise.all([ - // Register project manager first to ensure it's available - registerProjectManager(context), - // Register welcome view provider - registerWelcomeViewProvider(context), + registerSmartEditCommands(context), + registerProviders(context), + registerCommands(context), + initializeWebviews(context), ]); - stepStart = trackTiming("Setting up Basic Components", parallelInitStart); - // Register startup flow commands after auth is available - const startupStart = globalThis.performance.now(); + // Register metadata commands for frontier-authentication to call + registerMetadataCommands(context); + + // Register startup flow commands await registerStartupFlowCommands(context); registerPreflightCommand(context); @@ -428,97 +149,95 @@ export async function activate(context: vscode.ExtensionContext) { const { registerProjectSwapCommands } = await import("./commands/projectSwapCommands"); registerProjectSwapCommands(context); - stepStart = trackTiming("Configuring Startup Workflow", startupStart); + // Initialize status bar for auto-complete + autoCompleteStatusBarItem = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Right, + 100 + ); + autoCompleteStatusBarItem.text = "$(sync~spin) Auto-completing..."; + autoCompleteStatusBarItem.hide(); + context.subscriptions.push(autoCompleteStatusBarItem); + + // Initialize A/B testing registry + initializeABTesting(); + + } catch (error) { + console.error("Error registering providers:", error); + } + + // Register additional commands + registerAdditionalCommands(context); - // Initialize SqlJs with real-time progress since it loads WASM files - // Only initialize database if we have a workspace (database is for project content) + console.log(`[Activation] UI ready in ${(performance.now() - activationStart).toFixed(0)}ms`); + + // 3. Fire-and-forget background initialization + void initializeInBackground(context, statusBar); +} + +/** + * Background initialization - all heavy operations run concurrently + */ +async function initializeInBackground( + context: vscode.ExtensionContext, + statusBar: StartupStatusBar +): Promise { + const bgStart = performance.now(); + + try { + statusBar.update("Setting up workspace..."); + + // Execute pre-activation commands + await executeCommandsBefore(context); + + // Check for untrusted workspace early const workspaceFolders = vscode.workspace.workspaceFolders; + if (workspaceFolders && workspaceFolders.length > 0 && !vscode.workspace.isTrusted) { + statusBar.complete("Workspace needs trust"); + vscode.window + .showWarningMessage( + "This workspace needs to be trusted before Codex Editor can fully activate.", + "Trust Workspace" + ) + .then((selection) => { + if (selection === "Trust Workspace") { + vscode.commands.executeCommand("workbench.action.trustWorkspace"); + } + }); + setTimeout(() => statusBar.hide(), 3000); + return; + } - // Check for pending swap downloads (after workspace is ready) + // Run initialization tasks concurrently + // Note: NotebookMetadataManager is already initialized in activate() before providers + // Store auth promise so other code can wait for it + authInitPromise = initializeAuth(statusBar); + const initTasks: Promise[] = [ + authInitPromise, + ]; + + // Add workspace-specific tasks only if we have a workspace if (workspaceFolders && workspaceFolders.length > 0) { + initTasks.push(initializeDatabase(context, statusBar)); + initTasks.push(migrateComments(statusBar)); + initTasks.push(updateGitConfig(statusBar)); + + // Check for pending swap downloads (after workspace is ready) checkPendingSwapDownloads(workspaceFolders[0].uri).catch(err => { console.error("[Extension] Error checking pending swap downloads:", err); }); } - if (workspaceFolders && workspaceFolders.length > 0) { - startRealtimeStep("AI preparing search capabilities"); - try { - global.db = await initializeSqlJs(context); - } catch (error) { - console.error("Error initializing SqlJs:", error); - } - stepStart = finishRealtimeStep(); - if (global.db) { - const importCommand = vscode.commands.registerCommand( - "extension.importWiktionaryJSONL", - () => global.db && importWiktionaryJSONL(global.db) - ); - context.subscriptions.push(importCommand); - registerLookupWordCommand(global.db, context); - ingestJsonlDictionaryEntries(global.db); - } - } else { - // No workspace, skip database initialization - stepStart = trackTiming("AI search capabilities (skipped - no workspace)", globalThis.performance.now()); - } + await Promise.allSettled(initTasks); - vscode.workspace.getConfiguration().update("workbench.startupEditor", "none", true); - - // Initialize extension based on workspace state - const workspaceStart = globalThis.performance.now(); + // Check for existing project and initialize extension if (workspaceFolders && workspaceFolders.length > 0) { - if (!vscode.workspace.isTrusted) { - - vscode.window - .showWarningMessage( - "This workspace needs to be trusted before Codex Editor can fully activate.", - "Trust Workspace" - ) - .then((selection) => { - if (selection === "Trust Workspace") { - vscode.commands.executeCommand("workbench.action.trustWorkspace"); - } - }); - return; - } - - // Check for pending project creation after reload - const pendingCreate = context.globalState.get("pendingProjectCreate"); - if (pendingCreate) { - const pendingName = context.globalState.get("pendingProjectCreateName"); - const pendingProjectId = context.globalState.get("pendingProjectCreateId"); - console.debug("[Extension] Resuming project creation for:", pendingName, "with projectId:", pendingProjectId); - - // Clear flags - await context.globalState.update("pendingProjectCreate", undefined); - await context.globalState.update("pendingProjectCreateName", undefined); - await context.globalState.update("pendingProjectCreateId", undefined); - - try { - // We are in the new folder. Initialize it. - const { createNewProject } = await import("./utils/projectCreationUtils/projectCreationUtils"); - await createNewProject({ projectName: pendingName, projectId: pendingProjectId }); - } catch (error) { - console.error("Failed to resume project creation:", error); - vscode.window.showErrorMessage("Failed to create project after reload."); - } - } - const metadataUri = vscode.Uri.joinPath(workspaceFolders[0].uri, "metadata.json"); - let metadataExists = false; try { - // DEBUGGING: Here is where the splash screen disappears - it was visible up till now await vscode.workspace.fs.stat(metadataUri); metadataExists = true; - // Note: validateAndFixProjectId is now called AFTER migrations complete - // to ensure projectName updates aren't overwritten by migrations - // Ensure all installed extension versions are recorded in metadata - // This handles: 1) Adding missing versions (e.g., frontierAuthentication added after project creation) - // 2) Updating to newer versions (never downgrades) try { await MetadataManager.ensureExtensionVersionsRecorded(workspaceFolders[0].uri); } catch (error) { @@ -528,92 +247,223 @@ export async function activate(context: vscode.ExtensionContext) { metadataExists = false; } - trackTiming("Initializing Workspace", workspaceStart); - - // Always initialize extension to ensure language server is available before webviews - await initializeExtension(context, metadataExists); + // Check for pending project creation + await handlePendingProjectCreation(context); - // Ensure local project settings exist when a Codex project is open - try { - if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) { - // Only ensure settings once a repo is fully initialized (avoid during clone checkout) - try { - const projectUri = vscode.workspace.workspaceFolders[0].uri; - const gitDir = vscode.Uri.joinPath(projectUri, ".git"); - await vscode.workspace.fs.stat(gitDir); - const { afterProjectDetectedEnsureLocalSettings } = await import("./projectManager/utils/projectUtils"); - await afterProjectDetectedEnsureLocalSettings(projectUri); - } catch { - // No .git yet; skip until project is fully initialized/opened - } - } - } catch (e) { - console.warn("[Extension] Failed to ensure local project settings exist:", e); + // Initialize language server and indexing (depends on metadata) + if (metadataExists) { + statusBar.update("Starting language server..."); + await initializeLanguageServerAndIndex(context, statusBar); + } else { + // Watch for project initialization + await watchForInitialization(context, metadataUri); } - if (!metadataExists) { - const watchStart = globalThis.performance.now(); - await watchForInitialization(context, metadataUri); - trackTiming("Watching for Initialization", watchStart); + // Ensure local project settings exist + try { + const projectUri = workspaceFolders[0].uri; + const gitDir = vscode.Uri.joinPath(projectUri, ".git"); + await vscode.workspace.fs.stat(gitDir); + const { afterProjectDetectedEnsureLocalSettings } = await import("./projectManager/utils/projectUtils"); + await afterProjectDetectedEnsureLocalSettings(projectUri); + } catch { + // No .git yet; skip } } else { + // No workspace - show project overview vscode.commands.executeCommand("codex-project-manager.showProjectOverview"); - trackTiming("Initializing Workspace", workspaceStart); } - // Register remaining components in parallel - const coreComponentsStart = globalThis.performance.now(); + // Run post-activation tasks + statusBar.update("Running migrations..."); + await runMigrations(context); - await Promise.all([ - registerSmartEditCommands(context), - registerProviders(context), - registerCommands(context), - initializeWebviews(context), - (async () => registerTestingCommands(context))(), - ]); + // Sync if authenticated and have a project + statusBar.update("Syncing..."); + await runInitialSync(context); - // Register metadata commands for frontier-authentication to call - // This implements the "single writer" principle - only codex-editor writes to metadata.json - registerMetadataCommands(context); + // Execute post-activation commands + await executeCommandsAfter(context); - // Initialize A/B testing registry (always-on) - initializeABTesting(); + // Mark startup as complete - welcome view can now show when all editors are closed + setStartupComplete(); - // Track total time for core components - stepStart = trackTiming("Loading Core Components", coreComponentsStart); + // Show welcome view if no editors are open + await showWelcomeViewIfNeeded(); - // Initialize status bar - const statusBarStart = globalThis.performance.now(); - autoCompleteStatusBarItem = vscode.window.createStatusBarItem( - vscode.StatusBarAlignment.Right, - 100 - ); - autoCompleteStatusBarItem.text = "$(sync~spin) Auto-completing..."; - autoCompleteStatusBarItem.hide(); - context.subscriptions.push(autoCompleteStatusBarItem); - stepStart = trackTiming("Initializing Status Bar", statusBarStart); + // Run preflight check now that auth and other services are initialized + // This ensures the startup flow only opens if actually needed + vscode.commands.executeCommand("codex-project-manager.preflight"); - // Show activation summary - const totalDuration = globalThis.performance.now() - activationStart; - // Don't add "Total Activation Time" to timings array since it's already calculated above - debug(`[Activation] Total Activation Time: ${totalDuration.toFixed(2)}ms`); + // Register update commands and check for updates + registerUpdateCommands(context); + checkForUpdatesOnStartup(context).catch(error => { + console.error('[Extension] Error during startup update check:', error); + }); - // Sort timings by duration (descending) and format the message - const sortedTimings = [...activationTimings].sort((a, b) => b.duration - a.duration); - const summaryMessage = [ - `Codex Editor activated in ${totalDuration.toFixed(2)}ms`, - "", - "Top 5 longest steps:", - ...sortedTimings.slice(0, 5).map((t) => `${t.step}: ${t.duration.toFixed(2)}ms`), - ].join("\n"); + const bgDuration = performance.now() - bgStart; + console.log(`[Activation] Background initialization completed in ${bgDuration.toFixed(0)}ms`); - console.info(summaryMessage); + statusBar.complete("Ready"); + setTimeout(() => statusBar.hide(), 3000); - // Execute post-activation tasks - const postActivationStart = globalThis.performance.now(); + } catch (error) { + console.error("Error during background initialization:", error); + statusBar.complete("Ready (with errors)"); + setTimeout(() => statusBar.hide(), 3000); + // Still mark startup complete even on error so welcome view can work + setStartupComplete(); + } +} - await executeCommandsAfter(context); - // NOTE: migration_chatSystemMessageSetting() now runs BEFORE sync (see line ~768) +/** + * Initialize authentication API + */ +async function initializeAuth(statusBar: StartupStatusBar): Promise { + try { + statusBar.update("Connecting authentication..."); + const extension = await waitForExtensionActivation("frontier-rnd.frontier-authentication"); + if (extension?.isActive) { + authApi = extension.exports; + } + } catch (error) { + console.error("[Extension] Error initializing auth:", error); + } finally { + authInitComplete = true; + } +} + +/** + * Initialize SQLite database for dictionary/search + */ +async function initializeDatabase(context: vscode.ExtensionContext, statusBar: StartupStatusBar): Promise { + try { + statusBar.update("Preparing search..."); + global.db = await initializeSqlJs(context); + + if (global.db) { + const importCommand = vscode.commands.registerCommand( + "extension.importWiktionaryJSONL", + () => global.db && importWiktionaryJSONL(global.db) + ); + context.subscriptions.push(importCommand); + registerLookupWordCommand(global.db, context); + ingestJsonlDictionaryEntries(global.db); + } + } catch (error) { + console.error("[Extension] Error initializing database:", error); + } +} + +/** + * Migrate comments early during startup + */ +async function migrateComments(statusBar: StartupStatusBar): Promise { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) return; + + try { + statusBar.update("Migrating comments..."); + await CommentsMigrator.migrateProjectComments(workspaceFolders[0].uri); + + // Also repair any existing corrupted data + const commentsFilePath = vscode.Uri.joinPath(workspaceFolders[0].uri, ".project", "comments.json"); + CommentsMigrator.repairExistingCommentsFile(commentsFilePath, true).catch(() => { + // Silent fallback + }); + } catch (error) { + console.error("[Extension] Error during comments migration:", error); + } +} + +/** + * Update git configuration files + */ +async function updateGitConfig(statusBar: StartupStatusBar): Promise { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) return; + + try { + statusBar.update("Updating git config..."); + const { ensureGitConfigsAreUpToDate } = await import("./projectManager/utils/projectUtils"); + await ensureGitConfigsAreUpToDate(); + debug("[Extension] Git configuration files updated"); + } catch (error) { + console.error("[Extension] Error updating git config:", error); + } +} + +/** + * Initialize language server and content indexing + */ +async function initializeLanguageServerAndIndex( + context: vscode.ExtensionContext, + statusBar: StartupStatusBar +): Promise { + try { + // Start language server + statusBar.update("Starting language server..."); + client = await registerLanguageServer(context); + + // Register client commands + clientCommandsDisposable = registerClientCommands(context, client); + context.subscriptions.push(clientCommandsDisposable); + + if (client && global.db) { + try { + await registerClientOnRequests(client, global.db); + await client.start(); + } catch (error) { + console.error("Error starting language client:", error); + } + } else { + if (!client) { + console.warn("Language server failed to initialize - spellcheck will use fallback"); + } + if (!global.db) { + console.info("[Database] Dictionary not available - dictionary features limited"); + } + } + + // Create content indexes + statusBar.update("Indexing content..."); + await createIndexWithContext(context); + + } catch (error) { + console.error("[Extension] Error initializing language server:", error); + } +} + +/** + * Handle pending project creation after reload + */ +async function handlePendingProjectCreation(context: vscode.ExtensionContext): Promise { + const pendingCreate = context.globalState.get("pendingProjectCreate"); + if (!pendingCreate) return; + + const pendingName = context.globalState.get("pendingProjectCreateName"); + const pendingProjectId = context.globalState.get("pendingProjectCreateId"); + debug("[Extension] Resuming project creation for:", pendingName); + + // Clear flags + await context.globalState.update("pendingProjectCreate", undefined); + await context.globalState.update("pendingProjectCreateName", undefined); + await context.globalState.update("pendingProjectCreateId", undefined); + + try { + const { createNewProject } = await import("./utils/projectCreationUtils/projectCreationUtils"); + await createNewProject({ projectName: pendingName, projectId: pendingProjectId }); + } catch (error) { + console.error("Failed to resume project creation:", error); + vscode.window.showErrorMessage("Failed to create project after reload."); + } +} + +/** + * Run all migrations + */ +async function runMigrations(context: vscode.ExtensionContext): Promise { + try { await temporaryMigrationScript_checkMatthewNotebook(); await migration_changeDraftFolderToFilesFolder(); await migration_lineNumbersSettings(context); @@ -627,101 +477,171 @@ export async function activate(context: vscode.ExtensionContext) { await migration_addGlobalReferences(context); await migration_cellIdsToUuid(context); await migration_recoverTempFilesAndMergeDuplicates(context); + } catch (error) { + console.error("[Extension] Error running migrations:", error); + } +} - // After migrations complete, trigger sync directly - // (All migrations have finished executing since they're awaited sequentially) - try { - const hasCodexProject = await checkIfMetadataAndGitIsInitialized(); - if (hasCodexProject) { - const workspaceFolderPath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; +/** + * Run initial sync after migrations + */ +async function runInitialSync(context: vscode.ExtensionContext): Promise { + try { + const hasCodexProject = await checkIfMetadataAndGitIsInitialized(); + if (!hasCodexProject) return; - const { ensureGitDisabledInSettings, validateAndFixProjectMetadata } = await import("./projectManager/utils/projectUtils"); - await ensureGitDisabledInSettings(); - debug("✅ [PRE-SYNC] Disabled VS Code Git before sync operations"); + const workspaceFolderPath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - // Auto-fix metadata structure (scope, name) on startup - try { - if (vscode.workspace.workspaceFolders?.[0]) { - await validateAndFixProjectMetadata(vscode.workspace.workspaceFolders[0].uri); - debug("✅ [PRE-SYNC] Validated and fixed project metadata structure"); - } - } catch (e) { - console.error("Error validating metadata on startup:", e); - } + // Disable VS Code Git before sync operations + const { ensureGitDisabledInSettings, validateAndFixProjectMetadata } = await import("./projectManager/utils/projectUtils"); + await ensureGitDisabledInSettings(); - const authApi = getAuthApi(); - if (authApi && typeof (authApi as any).getAuthStatus === "function") { - const authStatus = authApi.getAuthStatus(); - if (authStatus.isAuthenticated) { - // Validate and fix projectId/projectName AFTER migrations complete - // This ensures projectName updates aren't overwritten by migrations - const workspaceFolders = vscode.workspace.workspaceFolders; - if (workspaceFolders && workspaceFolders.length > 0) { - try { - const { validateAndFixProjectId } = await import("./utils/projectIdValidator"); - await validateAndFixProjectId(workspaceFolders[0].uri); - } catch (validationError) { - console.error("[Extension] Error validating projectId after migrations:", validationError); - } - } + // Auto-fix metadata structure (scope, name) on startup + try { + if (vscode.workspace.workspaceFolders?.[0]) { + await validateAndFixProjectMetadata(vscode.workspace.workspaceFolders[0].uri); + } + } catch (e) { + console.error("Error validating metadata on startup:", e); + } - // Check if this is an update workspace - const pendingUpdateSync = context.globalState.get("codex.pendingUpdateSync"); - const isUpdateWorkspace = - !!pendingUpdateSync && - typeof pendingUpdateSync.projectPath === "string" && - typeof workspaceFolderPath === "string" && - path.normalize(pendingUpdateSync.projectPath) === path.normalize(workspaceFolderPath); + const api = getAuthApi(); + if (!api || typeof (api as { getAuthStatus?: () => { isAuthenticated: boolean } }).getAuthStatus !== "function") return; - const syncManager = SyncManager.getInstance(); - if (isUpdateWorkspace && pendingUpdateSync?.commitMessage) { - await syncManager.executeSync(String(pendingUpdateSync.commitMessage), true, context, false); - await context.globalState.update("codex.pendingUpdateSync", undefined); - if (pendingUpdateSync?.showSuccessMessage) { - const projectName = pendingUpdateSync?.projectName || "Project"; - const backupFileName = pendingUpdateSync?.backupFileName; - vscode.window.showInformationMessage( - backupFileName - ? `Project "${projectName}" has been updated and synced successfully! Backup saved to: ${backupFileName}` - : `Project "${projectName}" has been updated and synced successfully!` - ); - } - } else { - await syncManager.executeSync("Initial workspace sync", true, context, false); - } - } - } + const authStatus = (api as { getAuthStatus: () => { isAuthenticated: boolean } }).getAuthStatus(); + if (!authStatus.isAuthenticated) return; + + // Validate and fix projectId/projectName AFTER migrations complete + const workspaceFolders = vscode.workspace.workspaceFolders; + if (workspaceFolders && workspaceFolders.length > 0) { + try { + const { validateAndFixProjectId } = await import("./utils/projectIdValidator"); + await validateAndFixProjectId(workspaceFolders[0].uri); + } catch (validationError) { + console.error("[Extension] Error validating projectId after migrations:", validationError); } - } catch (error) { - console.error("❌ [POST-MIGRATIONS] Error triggering sync after migrations:", error); } - trackTiming("Running Post-activation Tasks", postActivationStart); + // Check if this is an update workspace + const pendingUpdateSync = context.globalState.get<{ + projectPath?: string; + commitMessage?: string; + showSuccessMessage?: boolean; + projectName?: string; + backupFileName?: string; + }>("codex.pendingUpdateSync"); + const isUpdateWorkspace = + !!pendingUpdateSync && + typeof pendingUpdateSync.projectPath === "string" && + typeof workspaceFolderPath === "string" && + path.normalize(pendingUpdateSync.projectPath) === path.normalize(workspaceFolderPath); + + const syncManager = SyncManager.getInstance(); + if (isUpdateWorkspace && pendingUpdateSync?.commitMessage) { + await syncManager.executeSync(String(pendingUpdateSync.commitMessage), true, context, false); + await context.globalState.update("codex.pendingUpdateSync", undefined); + if (pendingUpdateSync?.showSuccessMessage) { + const projectName = pendingUpdateSync?.projectName || "Project"; + const backupFileName = pendingUpdateSync?.backupFileName; + vscode.window.showInformationMessage( + backupFileName + ? `Project "${projectName}" has been updated and synced successfully! Backup saved to: ${backupFileName}` + : `Project "${projectName}" has been updated and synced successfully!` + ); + } + } else { + await syncManager.executeSync("Initial workspace sync", true, context, false); + } + } catch (error) { + console.error("[Extension] Error during initial sync:", error); + } +} - // Register update commands and check for updates (non-blocking) - registerUpdateCommands(context); +let watcher: vscode.FileSystemWatcher | undefined; + +async function watchForInitialization(context: vscode.ExtensionContext, metadataUri: vscode.Uri): Promise { + watcher = vscode.workspace.createFileSystemWatcher("**/*"); - // Version checking removed from this extension + const statusBar = createStartupStatusBar(context); - // Don't close splash screen yet - we still have sync operations to show - // The splash screen will be closed after all operations complete - } catch (error) { - console.error("Error during extension activation:", error); - vscode.window.showErrorMessage(`Failed to activate Codex Editor: ${error}`); + const checkInitialization = async (): Promise => { + let metadataExists = false; + try { + await vscode.workspace.fs.stat(metadataUri); + metadataExists = true; + } catch { + metadataExists = false; + } + + if (metadataExists) { + watcher?.dispose(); + await initializeLanguageServerAndIndex(context, statusBar); + } + }; + + watcher.onDidCreate(checkInitialization); + watcher.onDidChange(checkInitialization); + watcher.onDidDelete(checkInitialization); + + context.subscriptions.push(watcher); +} + +async function executeCommandsBefore(context: vscode.ExtensionContext): Promise { + // Start status bar command non-blocking + void vscode.commands.executeCommand("workbench.action.toggleStatusbarVisibility"); + + // Batch all config updates + const config = vscode.workspace.getConfiguration(); + await Promise.all([ + config.update("workbench.statusBar.visible", false, true), + config.update("breadcrumbs.filePath", "last", true), + config.update("breadcrumbs.enabled", false, true), + config.update("workbench.editor.editorActionsLocation", "hidden", true), + config.update("workbench.editor.showTabs", "multiple", true), + config.update("window.autoDetectColorScheme", true, true), + config.update("workbench.editor.revealIfOpen", true, true), + config.update("workbench.layoutControl.enabled", false, true), + config.update("workbench.tips.enabled", false, true), + config.update("workbench.editor.limit.perEditorGroup", false, true), + config.update("workbench.editor.limit.value", 10, true), + config.update("workbench.startupEditor", "none", true), + ]); + + registerCommandsBefore(context); +} + +async function executeCommandsAfter(context: vscode.ExtensionContext): Promise { + // Set editor font - non-critical, silently skip if command not available + try { + await vscode.commands.executeCommand("codex-editor-extension.setEditorFontToTargetLanguage"); + } catch { + // Command may not be registered yet or font setting may fail - not critical } + // Configure auto-save + await vscode.workspace.getConfiguration().update("files.autoSave", "afterDelay", vscode.ConfigurationTarget.Global); + await vscode.workspace.getConfiguration().update("files.autoSaveDelay", 1000, vscode.ConfigurationTarget.Global); + await vscode.workspace.getConfiguration().update("codex-project-manager.spellcheckIsEnabled", false, vscode.ConfigurationTarget.Global); + + await vscode.commands.executeCommand("workbench.action.evenEditorWidths"); +} + +/** + * Register additional commands that don't need to be in the critical path + */ +function registerAdditionalCommands(context: vscode.ExtensionContext): void { context.subscriptions.push( vscode.commands.registerCommand("codex-editor.openCellLabelImporter", () => openCellLabelImporter(context) ) ); + context.subscriptions.push( vscode.commands.registerCommand("codex-editor.openCodexMigrationTool", () => openCodexMigrationTool(context) ) ); - // Command: Migrate validations for user edits across project context.subscriptions.push( vscode.commands.registerCommand( "codex-editor-extension.migrateValidationsForUserEdits", @@ -731,46 +651,42 @@ export async function activate(context: vscode.ExtensionContext) { ) ); - // Comments-related commands context.subscriptions.push( vscode.commands.registerCommand("codex-editor-extension.focusCommentsView", () => { vscode.commands.executeCommand("comments-sidebar.focus"); }) ); - // Ensure sync commands exist in all environments (including tests) - try { - const cmds = await vscode.commands.getCommands(true); - if (!cmds.includes("extension.scheduleSync")) { - const { SyncManager } = await import("./projectManager/syncManager"); - context.subscriptions.push( - vscode.commands.registerCommand("extension.scheduleSync", (message: string) => { - const syncManager = SyncManager.getInstance(); - syncManager.scheduleSyncOperation(message); - }) - ); + // Ensure sync commands exist + void (async () => { + try { + const cmds = await vscode.commands.getCommands(true); + if (!cmds.includes("extension.scheduleSync")) { + const { SyncManager } = await import("./projectManager/syncManager"); + context.subscriptions.push( + vscode.commands.registerCommand("extension.scheduleSync", (message: string) => { + const syncManager = SyncManager.getInstance(); + syncManager.scheduleSyncOperation(message); + }) + ); + } + } catch (err) { + console.warn("Failed to ensure scheduleSync registration", err); } - } catch (err) { - console.warn("Failed to ensure scheduleSync registration", err); - } + })(); context.subscriptions.push( vscode.commands.registerCommand("codex-editor-extension.navigateToCellInComments", (cellId: string) => { - // Get the comments provider and send reload message - const commentsProvider = GlobalProvider.getInstance().getProvider("comments-sidebar") as any; - if (commentsProvider && commentsProvider._view) { - // Send a reload message directly to the webview with the cellId + const commentsProvider = GlobalProvider.getInstance().getProvider("comments-sidebar") as { _view?: { webview: { postMessage: (msg: unknown) => void } } } | undefined; + if (commentsProvider?._view) { commentsProvider._view.webview.postMessage({ command: "reload", - data: { - cellId: cellId, - } + data: { cellId }, }); } }) ); - // Batch transcription command context.subscriptions.push( vscode.commands.registerCommand("codex-editor-extension.generateTranscriptions", async () => { const countInput = await vscode.window.showInputBox({ @@ -787,210 +703,22 @@ export async function activate(context: vscode.ExtensionContext) { return; } - provider.postMessageToWebviews({ type: "startBatchTranscription", content: { count } } as any); + provider.postMessageToWebviews({ type: "startBatchTranscription", content: { count } } as { type: string; content: { count: number } }); vscode.window.showInformationMessage(`Starting transcription for up to ${count} cells...`); }) ); - // Register the missing comments-sidebar.reload command context.subscriptions.push( - vscode.commands.registerCommand("codex-editor-extension.comments-sidebar.reload", (options: any) => { - // Get the comments provider and send reload message - const commentsProvider = GlobalProvider.getInstance().getProvider("comments-sidebar") as any; - if (commentsProvider && commentsProvider._view) { - // Send a reload message directly to the webview + vscode.commands.registerCommand("codex-editor-extension.comments-sidebar.reload", (options: unknown) => { + const commentsProvider = GlobalProvider.getInstance().getProvider("comments-sidebar") as { _view?: { webview: { postMessage: (msg: unknown) => void } } } | undefined; + if (commentsProvider?._view) { commentsProvider._view.webview.postMessage({ command: "reload", - data: options + data: options, }); } }) ); - - - - -} - -async function initializeExtension(context: vscode.ExtensionContext, metadataExists: boolean) { - const initStart = globalThis.performance.now(); - - debug("Initializing extension"); - - if (metadataExists) { - // Break down language server initialization - const totalLsStart = globalThis.performance.now(); - - startRealtimeStep("Initializing Language Server"); - const lsStart = globalThis.performance.now(); - client = await registerLanguageServer(context); - const lsDuration = globalThis.performance.now() - lsStart; - debug(`[Activation] Start Language Server: ${lsDuration.toFixed(2)}ms`); - - // Always register client commands to prevent "command not found" errors - // If language server failed, commands will return appropriate fallbacks - const regServicesStart = globalThis.performance.now(); - clientCommandsDisposable = registerClientCommands(context, client); - context.subscriptions.push(clientCommandsDisposable); - const regServicesDuration = globalThis.performance.now() - regServicesStart; - debug(`[Activation] Register Language Services: ${regServicesDuration.toFixed(2)}ms`); - - if (client && global.db) { - const optimizeStart = globalThis.performance.now(); - try { - await registerClientOnRequests(client, global.db); - await client.start(); - } catch (error) { - console.error("Error registering client requests:", error); - } - const optimizeDuration = globalThis.performance.now() - optimizeStart; - debug(`[Activation] Optimize Language Processing: ${optimizeDuration.toFixed(2)}ms`); - } else { - if (!client) { - console.warn("Language server failed to initialize - spellcheck and alert features will use fallback behavior"); - } - if (!global.db) { - console.info("[Database] Dictionary not available - dictionary features will be limited. This is normal during initial setup or if database initialization failed."); - } - } - finishRealtimeStep(); - const totalLsDuration = globalThis.performance.now() - totalLsStart; - debug(`[Activation] Language Server Ready: ${totalLsDuration.toFixed(2)}ms`); - - // Break down index creation - const totalIndexStart = globalThis.performance.now(); - - const verseRefsStart = globalThis.performance.now(); - // Index verse refs would go here, but it seems to be missing from this section - const verseRefsDuration = globalThis.performance.now() - verseRefsStart; - debug(`[Activation] Index Verse Refs: ${verseRefsDuration.toFixed(2)}ms`); - - // Use real-time progress for context index setup since it can take a while - // Note: SQLiteIndexManager handles its own detailed progress tracking - startRealtimeStep("AI learning your project structure"); - await createIndexWithContext(context); - finishRealtimeStep(); - - // Don't track "Total Index Creation" since it would show cumulative time - // The individual steps above already show the breakdown - const totalIndexDuration = globalThis.performance.now() - totalIndexStart; - debug(`[AI Learning] Total AI learning preparation: ${totalIndexDuration.toFixed(2)}ms`); - - // Skip version check during splash screen - will be performed before sync - updateSplashScreenSync(50, "Finalizing initialization..."); - - // Skip sync during splash screen - will be performed after workspace loads - updateSplashScreenSync(100, "Initialization complete"); - debug("✅ [SPLASH SCREEN PHASE] Extension initialization complete, sync will run after workspace loads"); - } - - // Calculate and log total initialize extension time but don't add to main timing array - // since it's a summary of the sub-steps already tracked - const totalInitDuration = globalThis.performance.now() - initStart; - debug(`[Activation] Total Initialize Extension: ${totalInitDuration.toFixed(2)}ms`); -} - -let watcher: vscode.FileSystemWatcher | undefined; - -async function watchForInitialization(context: vscode.ExtensionContext, metadataUri: vscode.Uri) { - watcher = vscode.workspace.createFileSystemWatcher("**/*"); - - const checkInitialization = async () => { - let metadataExists = false; - try { - await vscode.workspace.fs.stat(metadataUri); - metadataExists = true; - } catch { - metadataExists = false; - } - - if (metadataExists) { - watcher?.dispose(); - await initializeExtension(context, metadataExists); - } - }; - - watcher.onDidCreate(checkInitialization); - watcher.onDidChange(checkInitialization); - watcher.onDidDelete(checkInitialization); - - context.subscriptions.push(watcher); -} - -async function executeCommandsBefore(context: vscode.ExtensionContext) { - // Start status bar command non-blocking - void vscode.commands.executeCommand("workbench.action.toggleStatusbarVisibility"); - - // Batch all config updates with Promise.all instead of sequential awaits - const config = vscode.workspace.getConfiguration(); - await Promise.all([ - config.update("workbench.statusBar.visible", false, true), - config.update("breadcrumbs.filePath", "last", true), - config.update("breadcrumbs.enabled", false, true), // hide breadcrumbs for now... it shows the file name which cannot be localized - config.update("workbench.editor.editorActionsLocation", "hidden", true), - config.update("workbench.editor.showTabs", "none", true), // Hide tabs during splash screen - config.update("window.autoDetectColorScheme", true, true), - config.update("workbench.editor.revealIfOpen", true, true), - config.update("workbench.layoutControl.enabled", false, true), - config.update("workbench.tips.enabled", false, true), - config.update("workbench.editor.limit.perEditorGroup", false, true), - config.update("workbench.editor.limit.value", 4, true), - ]); - - registerCommandsBefore(context); -} - -async function executeCommandsAfter( - context: vscode.ExtensionContext -) { - try { - // Update splash screen for post-activation tasks - updateSplashScreenSync(90, "Configuring editor settings..."); - - await vscode.commands.executeCommand( - "codex-editor-extension.setEditorFontToTargetLanguage" - ); - } catch (error) { - console.warn("Failed to set editor font, possibly due to network issues:", error); - } - - // Configure auto-save in settings - await vscode.workspace - .getConfiguration() - .update("files.autoSave", "afterDelay", vscode.ConfigurationTarget.Global); - await vscode.workspace - .getConfiguration() - .update("files.autoSaveDelay", 1000, vscode.ConfigurationTarget.Global); - - await vscode.workspace - .getConfiguration() - .update("codex-project-manager.spellcheckIsEnabled", false, vscode.ConfigurationTarget.Global); - - // Final splash screen update and close - updateSplashScreenSync(100, "Finalizing setup..."); - - // Close splash screen and then check if we need to show the welcome view - closeSplashScreen(async () => { - debug( - "[Extension] Splash screen closed, checking if welcome view needs to be shown" - ); - // Show tabs again after splash screen closes - await vscode.workspace - .getConfiguration() - .update("workbench.editor.showTabs", "multiple", true); - // Restore tab layout after splash screen closes - await restoreTabLayout(context); - - // Check if we need to show the welcome view after initialization - await showWelcomeViewIfNeeded(); - }); - - await vscode.commands.executeCommand("workbench.action.evenEditorWidths"); - - // Check for updates in the background after everything else is ready - checkForUpdatesOnStartup(context).catch(error => { - console.error('[Extension] Error during startup update check:', error); - }); } /** @@ -999,7 +727,7 @@ async function executeCommandsAfter( */ async function checkPendingSwapDownloads(projectUri: vscode.Uri): Promise { try { - const { getSwapPendingState, checkPendingDownloadsComplete, clearSwapPendingState, downloadPendingSwapFiles, saveSwapPendingState, performProjectSwap } = + const { getSwapPendingState, checkPendingDownloadsComplete, downloadPendingSwapFiles, performProjectSwap } = await import("./providers/StartupFlow/performProjectSwap"); const pendingState = await getSwapPendingState(projectUri.fsPath); @@ -1011,15 +739,13 @@ async function checkPendingSwapDownloads(projectUri: vscode.Uri): Promise console.log("[Extension] Found pending swap downloads, starting automatic download..."); // Check if downloads are already complete - const { complete: alreadyComplete, remaining } = await checkPendingDownloadsComplete(projectUri.fsPath); + const { complete: alreadyComplete } = await checkPendingDownloadsComplete(projectUri.fsPath); if (alreadyComplete) { - // Already done - show continue modal await promptContinueSwap(projectUri, pendingState); return; } - // Show progress and automatically download the files const totalFiles = pendingState.filesNeedingDownload.length; await vscode.window.withProgress({ @@ -1027,9 +753,7 @@ async function checkPendingSwapDownloads(projectUri: vscode.Uri): Promise title: "Downloading media for swap...", cancellable: true }, async (progress, token) => { - // Show initial count in message progress.report({ message: `0/${totalFiles} files` }); - // Set up cancellation handler let cancelled = false; token.onCancellationRequested(() => { cancelled = true; @@ -1047,7 +771,6 @@ async function checkPendingSwapDownloads(projectUri: vscode.Uri): Promise console.log(`[Extension] Download complete: ${result.downloaded}/${result.total}, failed: ${result.failed.length}`); if (result.failed.length > 0) { - // Some downloads failed - show warning and let user decide const action = await vscode.window.showWarningMessage( `Downloaded ${result.downloaded}/${result.total} files. ${result.failed.length} file(s) failed to download. Continue with swap anyway?`, { modal: true }, @@ -1057,7 +780,6 @@ async function checkPendingSwapDownloads(projectUri: vscode.Uri): Promise ); if (action === "Retry") { - // Reopen to retry vscode.commands.executeCommand("workbench.action.reloadWindow"); } else if (action === "Continue Swap") { await promptContinueSwap(projectUri, pendingState); @@ -1065,7 +787,6 @@ async function checkPendingSwapDownloads(projectUri: vscode.Uri): Promise await cancelSwap(projectUri, pendingState); } } else { - // All downloads successful await promptContinueSwap(projectUri, pendingState); } }); @@ -1078,8 +799,9 @@ async function checkPendingSwapDownloads(projectUri: vscode.Uri): Promise /** * Show modal to continue or cancel swap after downloads complete */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any async function promptContinueSwap(projectUri: vscode.Uri, pendingState: any): Promise { - const { saveSwapPendingState, performProjectSwap, clearSwapPendingState } = + const { saveSwapPendingState, performProjectSwap } = await import("./providers/StartupFlow/performProjectSwap"); const newProjectName = pendingState.newProjectUrl.split('/').pop()?.replace('.git', '') || 'new project'; @@ -1091,13 +813,82 @@ async function promptContinueSwap(projectUri: vscode.Uri, pendingState: any): Pr ); if (action === "Continue Swap") { + // Re-validate swap is still active before executing + try { + const { checkProjectSwapRequired } = await import("./utils/projectSwapManager"); + const recheck = await checkProjectSwapRequired(projectUri.fsPath, undefined, true); + if (recheck.remoteUnreachable) { + await vscode.window.showWarningMessage( + "Server Unreachable\n\n" + + "The swap cannot be completed because the server is not reachable. " + + "Please check your internet connection or try again later.\n\n" + + "The pending swap state has been preserved and will resume when connectivity is restored.", + { modal: true }, + "OK" + ); + return; // Don't clear pending state - preserve for when connectivity returns + } + if (recheck.userAlreadySwapped && recheck.activeEntry) { + // User already completed this swap - clear pending state and inform + const { clearSwapPendingState: clearPending } = await import("./providers/StartupFlow/performProjectSwap"); + await clearPending(projectUri.fsPath); + + const swapTargetLabel = + recheck.activeEntry.newProjectName || recheck.activeEntry.newProjectUrl || "the new project"; + await vscode.window.showWarningMessage( + `Already Swapped\n\n` + + `You have already swapped to ${swapTargetLabel}.\n\n` + + `This project is deprecated but can still be opened.`, + { modal: true }, + "OK" + ); + return; + } + if (!recheck.required || !recheck.activeEntry || recheck.activeEntry.swapUUID !== pendingState.swapUUID) { + // Update local metadata with merged data + if (recheck.swapInfo) { + try { + const { sortSwapEntries, orderEntryFields } = await import("./utils/projectSwapManager"); + await MetadataManager.safeUpdateMetadata( + projectUri, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (meta: any) => { + if (!meta.meta) { meta.meta = {}; } + const sorted = sortSwapEntries(recheck.swapInfo!.swapEntries || []); + meta.meta.projectSwap = { swapEntries: sorted.map(orderEntryFields) }; + return meta; + } + ); + } catch { /* non-fatal */ } + } + + // Clean up localProjectSwap.json + try { + const { deleteLocalProjectSwapFile } = await import("./utils/localProjectSettings"); + await deleteLocalProjectSwapFile(projectUri); + } catch { /* non-fatal */ } + + const { clearSwapPendingState } = await import("./providers/StartupFlow/performProjectSwap"); + await clearSwapPendingState(projectUri.fsPath); + + await vscode.window.showWarningMessage( + "Swap Cancelled\n\n" + + "The project swap has been cancelled or is no longer required.", + { modal: true }, + "OK" + ); + return; + } + } catch { + // Non-fatal - proceed with swap if re-check fails + } + // Mark as ready and trigger swap await saveSwapPendingState(projectUri.fsPath, { ...pendingState, swapState: "ready_to_swap" }); - // Perform the swap const projectName = projectUri.fsPath.split(/[\\/]/).pop() || "project"; await vscode.window.withProgress({ @@ -1117,35 +908,27 @@ async function promptContinueSwap(projectUri: vscode.Uri, pendingState: any): Pr ); progress.report({ message: "Opening swapped project..." }); - const { MetadataManager } = await import("./utils/metadataManager"); await MetadataManager.safeOpenFolder( vscode.Uri.file(newPath), projectUri ); }); } else { - // User clicked Cancel or closed the modal await cancelSwap(projectUri, pendingState); } } /** * Cancel a pending swap. - * Since we no longer change media strategy during swap, just clear the pending state. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any async function cancelSwap(projectUri: vscode.Uri, _pendingState: any): Promise { const { clearSwapPendingState } = await import("./providers/StartupFlow/performProjectSwap"); await clearSwapPendingState(projectUri.fsPath); vscode.window.showInformationMessage("Project swap cancelled."); } -export function deactivate() { - // Clean up real-time progress timer - if (currentStepTimer) { - clearInterval(currentStepTimer); - currentStepTimer = null; - } - +export function deactivate(): Thenable | undefined { if (clientCommandsDisposable) { clientCommandsDisposable.dispose(); } @@ -1162,6 +945,8 @@ export function deactivate() { clearSQLiteIndexManager(); } ).catch(console.error); + + return undefined; } export function getAutoCompleteStatusBarItem(): StatusBarItem { @@ -1172,14 +957,28 @@ export function getNotebookMetadataManager(): NotebookMetadataManager { return notebookMetadataManager; } +/** + * Wait for auth initialization to complete. + * Call this before checking auth status to avoid race conditions. + */ +export function waitForAuthInit(): Promise { + return authInitPromise ?? Promise.resolve(); +} + +/** + * Check if auth initialization has completed (regardless of whether auth was found). + */ +export function isAuthInitComplete(): boolean { + return authInitComplete; +} + export function getAuthApi(): FrontierAPI | undefined { if (!authApi) { const extension = vscode.extensions.getExtension("frontier-rnd.frontier-authentication"); if (extension?.isActive) { - const exports = extension.exports as any; - // Defensive: only treat as auth API if it has expected surface area + const exports = extension.exports as { getAuthStatus?: () => unknown }; if (exports && typeof exports.getAuthStatus === "function") { - authApi = exports; + authApi = exports as FrontierAPI; } } } diff --git a/src/globalProvider.ts b/src/globalProvider.ts index d89f8a519..1d1c4aa47 100644 --- a/src/globalProvider.ts +++ b/src/globalProvider.ts @@ -28,6 +28,7 @@ export abstract class BaseWebviewProvider implements vscode.WebviewViewProvider // Common webview resolution public resolveWebviewView(webviewView: vscode.WebviewView) { + console.log(`[WebviewPerf] resolveWebviewView called for: ${this.getWebviewId()}`); this._view = webviewView; webviewView.webview.options = { diff --git a/src/projectManager/syncManager.ts b/src/projectManager/syncManager.ts index e47401f56..4a3661887 100644 --- a/src/projectManager/syncManager.ts +++ b/src/projectManager/syncManager.ts @@ -4,7 +4,6 @@ import { getAuthApi } from "../extension"; import { createIndexWithContext } from "../activationHelpers/contextAware/contentIndexes/indexes"; import { getNotebookMetadataManager } from "../utils/notebookMetadataManager"; import * as path from "path"; -import { updateSplashScreenSync } from "../providers/SplashScreen/register"; import git from "isomorphic-git"; import fs from "fs"; import http from "isomorphic-git/http/web"; @@ -149,7 +148,7 @@ export class SyncManager { // Force bypass cache to ensure we get the latest state from server const result = await checkProjectSwapRequired(projectPath, undefined, true); - if (result.required && result.activeEntry) { + if (result.required && result.activeEntry && !result.remoteUnreachable) { debug("Project swap required for user, blocking sync"); // Check if there are pending downloads for the swap @@ -224,7 +223,7 @@ export class SyncManager { // Force bypass cache to get the latest state (just synced, so remote might have new info) const result = await checkProjectSwapRequired(projectPath, undefined, true); - if (result.required && result.activeEntry) { + if (result.required && result.activeEntry && !result.remoteUnreachable) { // Check if there are pending downloads for the swap // If so, DON'T show the swap modal - let downloads complete first const { getSwapPendingState } = await import("../providers/StartupFlow/performProjectSwap"); @@ -744,9 +743,6 @@ export class SyncManager { this.showSyncProgress(commitMessage); } - // Update splash screen with initial sync status - updateSplashScreenSync(30, "Checking files are up to date..."); - // Run the actual sync operation in the background (truly async) this.executeSyncInBackground(commitMessage, showInfoOnConnectionIssues); @@ -764,10 +760,9 @@ export class SyncManager { const syncStartTime = performance.now(); debug("🔄 Starting background sync operation..."); - // Update sync stage and splash screen + // Update sync stage this.currentSyncStage = "Preparing sync..."; this.notifySyncStatusListeners(); - updateSplashScreenSync(60, this.currentSyncStage); // Migrate comments before sync if needed const workspaceFolders = vscode.workspace.workspaceFolders; @@ -785,7 +780,6 @@ export class SyncManager { if (needsMigration && inSourceControl) { this.currentSyncStage = "Migrating legacy comments..."; this.notifySyncStatusListeners(); - updateSplashScreenSync(65, this.currentSyncStage); try { await CommentsMigrator.migrateProjectComments(workspaceFolders[0].uri); @@ -801,7 +795,6 @@ export class SyncManager { // This ensures we clean up any local corruption before syncing to other users this.currentSyncStage = "Cleaning up comment data..."; this.notifySyncStatusListeners(); - updateSplashScreenSync(67, this.currentSyncStage); try { const commentsFilePath = vscode.Uri.joinPath(workspaceFolders[0].uri, ".project", "comments.json"); @@ -820,7 +813,6 @@ export class SyncManager { if (syncResult.offline) { this.currentSyncStage = "Synchronization skipped! (offline)"; this.notifySyncStatusListeners(); - updateSplashScreenSync(100, "Synchronization skipped (offline)"); return; } @@ -853,7 +845,6 @@ export class SyncManager { if (needsPostSyncMigration && inSourceControl) { this.currentSyncStage = "Cleaning up legacy files..."; this.notifySyncStatusListeners(); - updateSplashScreenSync(95, this.currentSyncStage); try { await CommentsMigrator.migrateProjectComments(workspaceFolders[0].uri); @@ -919,10 +910,9 @@ export class SyncManager { // Don't fail sync completion due to cleanup errors } - // Update sync stage and splash screen + // Update sync stage this.currentSyncStage = "Synchronization complete!"; this.notifySyncStatusListeners(); - updateSplashScreenSync(100, "Synchronization complete"); // Clear local update completion flag now that sync has pushed changes to remote try { @@ -962,10 +952,9 @@ export class SyncManager { console.error("Error during background sync operation:", error); const errorMessage = error instanceof Error ? error.message : String(error); - // Update sync stage and splash screen + // Update sync stage this.currentSyncStage = "Sync failed"; this.notifySyncStatusListeners(); - updateSplashScreenSync(100, `Sync failed: ${errorMessage}`); // Show error messages to user if ( diff --git a/src/projectManager/utils/projectUtils.ts b/src/projectManager/utils/projectUtils.ts index 7a5be7802..24b970aa6 100644 --- a/src/projectManager/utils/projectUtils.ts +++ b/src/projectManager/utils/projectUtils.ts @@ -1339,9 +1339,13 @@ async function filterSwappedProjects(projects: LocalProject[]): Promise 0 ? { swapEntries: mergedEntries } : undefined; }; - // Process all projects: collect data and deprecated URLs/names in one pass + // Process all projects: collect data and deprecated/hidden URLs/names in one pass const deprecatedUrls = new Set(); const deprecatedNames = new Set(); // Also track by name for remote-only projects + // Track NEW project URLs/names to hide when OLD project is local with active swap + // This prevents users from opening the new project directly and skipping the swap flow + const newProjectUrlsToHide = new Set(); + const newProjectNamesToHide = new Set(); const processedProjects: Array<{ project: LocalProject; swapInfo: ProjectSwapInfo | undefined; @@ -1376,11 +1380,11 @@ async function filterSwappedProjects(projects: LocalProject[]): Promise { // Check by URL first (most reliable) @@ -1413,6 +1427,15 @@ async function filterSwappedProjects(projects: LocalProject[]): Promise { diff --git a/src/projectManager/utils/versionChecks.ts b/src/projectManager/utils/versionChecks.ts index 225440f1d..d1dc26c4a 100644 --- a/src/projectManager/utils/versionChecks.ts +++ b/src/projectManager/utils/versionChecks.ts @@ -8,7 +8,7 @@ interface VSCodeVersionStatus { } // Required version of Frontier Authentication extension for all syncing operations (based on codex minimum requirements) -export const REQUIRED_FRONTIER_VERSION = "0.4.22"; // Prevent concurrent metadata.json changes by Frontier Authentication +export const REQUIRED_FRONTIER_VERSION = "0.4.23"; // Prevent concurrent metadata.json changes by Frontier Authentication // Required VS Code version for Codex Editor export const REQUIRED_VSCODE_VERSION = "1.99.0"; diff --git a/src/providers/AutomatedTestingProvider.ts b/src/providers/AutomatedTestingProvider.ts deleted file mode 100644 index d38d26488..000000000 --- a/src/providers/AutomatedTestingProvider.ts +++ /dev/null @@ -1,156 +0,0 @@ -import * as vscode from "vscode"; -import { BaseWebviewProvider } from "../globalProvider"; -import { safePostMessageToView } from "../utils/webviewUtils"; - -export class AutomatedTestingProvider extends BaseWebviewProvider { - public static readonly viewType = "codex-editor.automatedTesting"; - - constructor(context: vscode.ExtensionContext) { - super(context); - } - - protected getWebviewId(): string { - return "automated-testing-sidebar"; - } - - protected getScriptPath(): string[] { - return ["AutomatedTesting", "index.js"]; - } - - protected onWebviewResolved(webviewView: vscode.WebviewView): void { - safePostMessageToView(webviewView, { command: "webviewReady" }, "AutomatedTesting"); - } - - protected async handleMessage(message: any): Promise { - console.log('[AutomatedTestingProvider] Received message:', JSON.stringify(message, null, 2)); - switch (message.command) { - case "testConnection": { - console.log('[AutomatedTestingProvider] Test connection received!'); - if (this._view) { - this._view.webview.postMessage({ command: "testConnectionResponse", data: { success: true } }); - } - break; - } - case "runTest": { - const { cellIds, count = 10, onlyValidated = false } = message.data || {}; - try { - const result = await vscode.commands.executeCommand( - "codex-testing.runTest", - { cellIds, count, onlyValidated } - ); - if (this._view && result) { - this._view.webview.postMessage({ command: "testResults", data: result }); - } - } catch (e) { - console.error("Test failed:", e); - if (this._view) { - this._view.webview.postMessage({ - command: "testResults", - data: { averageCHRF: 0, results: [], error: String(e) } - }); - } - } - break; - } - case "getHistory": { - console.log('[AutomatedTestingProvider] Processing getHistory command'); - try { - const history = await vscode.commands.executeCommand( - "codex-testing.getTestHistory" - ); - console.log('[AutomatedTestingProvider] History data received:', JSON.stringify(history, null, 2)); - if (this._view) { - this._view.webview.postMessage({ command: "historyData", data: history }); - } - } catch (e) { - console.error('[AutomatedTestingProvider] Failed to load history:', e); - if (this._view) { - this._view.webview.postMessage({ command: "historyData", data: [] }); - } - } - break; - } - case "loadTest": { - const { path } = message.data || {}; - if (!path) return; - try { - const data: any = await vscode.commands.executeCommand( - "codex-testing.loadTest", - path - ); - if (this._view) { - this._view.webview.postMessage({ command: "testResults", data }); - } - } catch (e) { - console.error("Failed to load test:", e); - } - break; - } - case "populateCellIds": { - const { path } = message.data || {}; - if (!path) return; - try { - const data: any = await vscode.commands.executeCommand( - "codex-testing.loadTest", - path - ); - if (this._view && Array.isArray(data?.results)) { - const cellIds = (data.results as any[]).map((r: any) => r.cellId).join(", "); - this._view.webview.postMessage({ command: "cellIdsPopulated", data: { cellIds } }); - } - } catch (e) { - console.error("Failed to populate cell IDs:", e); - } - break; - } - case "reapplyConfig": { - const { path } = message.data || {}; - if (!path) return; - try { - const ok = await vscode.commands.executeCommand( - "codex-testing.reapplyConfigForTest", - path - ); - if (this._view) { - this._view.webview.postMessage({ command: "configReapplied", data: { ok } }); - } - } catch (e) { - console.error("Failed to reapply config:", e); - if (this._view) { - this._view.webview.postMessage({ command: "configReapplied", data: { ok: false } }); - } - } - break; - } - case "deleteTest": { - console.log('[AutomatedTestingProvider] Processing deleteTest command'); - const { path } = message.data || {}; - console.log('[AutomatedTestingProvider] Delete path:', path); - if (!path) { - console.error('[AutomatedTestingProvider] No path provided for deleteTest'); - return; - } - try { - console.log('[AutomatedTestingProvider] Executing codex-testing.deleteTest command with path:', path); - const success = await vscode.commands.executeCommand( - "codex-testing.deleteTest", - path - ); - console.log('[AutomatedTestingProvider] Delete command result:', success); - if (this._view) { - console.log('[AutomatedTestingProvider] Sending testDeleted response:', { success }); - this._view.webview.postMessage({ command: "testDeleted", data: { success } }); - } - } catch (e) { - console.error('[AutomatedTestingProvider] Failed to delete test:', e); - if (this._view) { - this._view.webview.postMessage({ command: "testDeleted", data: { success: false } }); - } - } - break; - } - } - } -} - - diff --git a/src/providers/NewSourceUploader/NewSourceUploaderProvider.ts b/src/providers/NewSourceUploader/NewSourceUploaderProvider.ts index 208c2a6b0..d78053b86 100644 --- a/src/providers/NewSourceUploader/NewSourceUploaderProvider.ts +++ b/src/providers/NewSourceUploader/NewSourceUploaderProvider.ts @@ -122,6 +122,62 @@ export class NewSourceUploaderProvider implements vscode.CustomTextEditorProvide command: "projectInventory", inventory: inventory, }); + } else if (message.command === "metadata.check") { + // Handle metadata check request + try { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + webviewPanel.webview.postMessage({ + command: "metadata.checkResponse", + data: { + sourceLanguage: null, + targetLanguage: null, + sourceTexts: [], + chatSystemMessage: null, + }, + }); + return; + } + + const metadataUri = vscode.Uri.joinPath( + workspaceFolders[0].uri, + "metadata.json" + ); + const metadataContent = await vscode.workspace.fs.readFile(metadataUri); + const metadata = JSON.parse(metadataContent.toString()); + + const sourceLanguage = metadata.languages?.find( + (l: any) => l.projectStatus === "source" + ); + const targetLanguage = metadata.languages?.find( + (l: any) => l.projectStatus === "target" + ); + const sourceTexts = metadata.ingredients + ? Object.keys(metadata.ingredients) + : []; + const chatSystemMessage = metadata.chatSystemMessage || null; + + webviewPanel.webview.postMessage({ + command: "metadata.checkResponse", + data: { + sourceLanguage, + targetLanguage, + sourceTexts, + chatSystemMessage, + }, + }); + } catch (error) { + console.error("Error checking metadata:", error); + webviewPanel.webview.postMessage({ + command: "metadata.checkResponse", + data: { + sourceLanguage: null, + targetLanguage: null, + sourceTexts: [], + chatSystemMessage: null, + }, + }); + } } else if (message.command === "writeNotebooks") { await this.handleWriteNotebooks(message as WriteNotebooksMessage, token, webviewPanel); } else if (message.command === "writeNotebooksWithAttachments") { @@ -325,6 +381,100 @@ export class NewSourceUploaderProvider implements vscode.CustomTextEditorProvide ); } else if (message.command === "saveFile") { await this.handleSaveFile(message as SaveFileMessage, webviewPanel); + } else if (message.command === "systemMessage.generate") { + // Generate AI system message for translation + try { + // Get workspace folder + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + webviewPanel.webview.postMessage({ + command: "systemMessage.generateError", + error: "No workspace folder found", + }); + return; + } + + // Get source and target languages from metadata + const metadataUri = vscode.Uri.joinPath( + workspaceFolders[0].uri, + "metadata.json" + ); + const metadataContent = await vscode.workspace.fs.readFile(metadataUri); + const metadata = JSON.parse(metadataContent.toString()); + + const sourceLanguage = metadata.languages?.find( + (l: any) => l.projectStatus === "source" + ); + const targetLanguage = metadata.languages?.find( + (l: any) => l.projectStatus === "target" + ); + + if (!sourceLanguage || !targetLanguage) { + webviewPanel.webview.postMessage({ + command: "systemMessage.generateError", + error: "Source and target languages must be set before generating system message", + }); + return; + } + + // Import and call the generation function + const { generateChatSystemMessage } = await import("../../copilotSettings/copilotSettings"); + const generatedMessage = await generateChatSystemMessage( + sourceLanguage, + targetLanguage, + workspaceFolders[0].uri + ); + + if (generatedMessage) { + // Save the generated message to metadata.json immediately + const { MetadataManager } = await import("../../utils/metadataManager"); + const saveResult = await MetadataManager.setChatSystemMessage( + generatedMessage, + workspaceFolders[0].uri + ); + + if (saveResult.success) { + webviewPanel.webview.postMessage({ + command: "systemMessage.generated", + message: generatedMessage, + }); + } else { + // Still send the generated message even if save fails + // User can manually save it later + webviewPanel.webview.postMessage({ + command: "systemMessage.generated", + message: generatedMessage, + }); + console.warn("Generated system message but failed to save:", saveResult.error); + } + } else { + webviewPanel.webview.postMessage({ + command: "systemMessage.generateError", + error: "Failed to generate system message. Please check your API configuration.", + }); + } + } catch (error) { + console.error("Error generating system message:", error); + webviewPanel.webview.postMessage({ + command: "systemMessage.generateError", + error: error instanceof Error ? error.message : "Failed to generate system message", + }); + } + } else if (message.command === "systemMessage.save") { + // Save system message to metadata + try { + const { MetadataManager } = await import("../../utils/metadataManager"); + await MetadataManager.setChatSystemMessage(message.message); + webviewPanel.webview.postMessage({ + command: "systemMessage.saved", + }); + } catch (error) { + console.error("Error saving system message:", error); + webviewPanel.webview.postMessage({ + command: "systemMessage.saveError", + error: error instanceof Error ? error.message : "Failed to save system message", + }); + } } } catch (error) { console.error("Error handling message:", error); @@ -1481,7 +1631,7 @@ export class NewSourceUploaderProvider implements vscode.CustomTextEditorProvide title: "Source File Importer", scriptPath: ["NewSourceUploader", "index.js"], // Using default CSP which already includes webview.cspSource for media-src - csp: `default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline'; script-src 'nonce-\${nonce}'; img-src data: https:; connect-src https: http:; media-src blob: data:;`, + csp: `default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline'; script-src 'nonce-\${nonce}'; img-src data: https:; font-src ${webview.cspSource}; connect-src https: http:; media-src blob: data:;`, inlineStyles: "#root { height: 100vh; width: 100vw; overflow-y: auto; }", customScript: "window.vscodeApi = acquireVsCodeApi();" }); diff --git a/src/providers/SplashScreen/SplashScreenProvider.ts b/src/providers/SplashScreen/SplashScreenProvider.ts deleted file mode 100644 index 9395fe298..000000000 --- a/src/providers/SplashScreen/SplashScreenProvider.ts +++ /dev/null @@ -1,190 +0,0 @@ -import * as vscode from "vscode"; -import { ActivationTiming } from "../../extension"; -import { getWebviewHtml } from "../../utils/webviewTemplate"; -import { safePostMessageToPanel } from "../../utils/webviewUtils"; - -const DEBUG_SPLASH_SCREEN_PROVIDER = false; -function debug(message: string, ...args: any[]): void { - if (DEBUG_SPLASH_SCREEN_PROVIDER) { - console.log(`[SplashScreenProvider] ${message}`, ...args); - } -} - -export interface SyncDetails { - progress: number; - message: string; - currentFile?: string; -} - -export class SplashScreenProvider { - public static readonly viewType = "codex-splash-screen"; - - private _panel?: vscode.WebviewPanel; - private readonly _extensionUri: vscode.Uri; - private _disposables: vscode.Disposable[] = []; - private _timings: ActivationTiming[] = []; - private _activationStart: number = 0; - private _syncDetails?: SyncDetails; - - constructor(extensionUri: vscode.Uri) { - this._extensionUri = extensionUri; - } - - public dispose() { - this._panel?.dispose(); - this._disposables.forEach((d) => d.dispose()); - } - - public async show(activationStart: number) { - this._activationStart = activationStart; - debug("[SplashScreen] Attempting to show splash screen..."); - - // If the panel is already showing, just reveal it - if (this._panel) { - debug("[SplashScreen] Panel already exists, revealing..."); - this._panel.reveal(vscode.ViewColumn.One); - return; - } - - // Create and show panel immediately - don't await UI commands that might delay visibility - debug("[SplashScreen] Creating new webview panel..."); - this._panel = vscode.window.createWebviewPanel( - SplashScreenProvider.viewType, - "Codex Editor", - { - viewColumn: vscode.ViewColumn.One, - preserveFocus: false, // Take focus immediately for loading screen experience - }, - { - enableScripts: true, - localResourceRoots: [this._extensionUri], - retainContextWhenHidden: true, - } - ); - debug("[SplashScreen] Panel created successfully"); - - // Set webview options - this._panel.webview.options = { - enableScripts: true, - localResourceRoots: [this._extensionUri], - }; - - // Immediately set the HTML content and reveal the panel - this._updateWebview(); - this._panel.reveal(vscode.ViewColumn.One, false); // Take focus for loading screen experience - debug("[SplashScreen] Panel revealed and focused"); - - // Execute UI commands in background after splash is visible - setTimeout(async () => { - try { - // Maximize editor and hide tab bar after splash is shown - await vscode.commands.executeCommand("workbench.action.maximizeEditorHideSidebar"); - debug("[SplashScreen] Maximized editor layout"); - } catch (error) { - console.warn("Failed to execute maximize command:", error); - } - }, 100); - - // Reset when the panel is disposed - this._panel.onDidDispose(() => { - debug("[SplashScreen] Panel disposed"); - this._panel = undefined; - }); - - // Handle messages from the webview - this._panel.webview.onDidReceiveMessage((message) => { - switch (message.command) { - case "animationComplete": - this._panel?.dispose(); - break; - case "close": - this._panel?.dispose(); - break; - } - }); - } - - public updateTimings(timings: ActivationTiming[]) { - this._timings = timings; - if (this._panel && !this._panel.webview) { - debug("[SplashScreen] Panel exists but webview is disposed, skipping update"); - return; - } - if (this._panel) { - safePostMessageToPanel(this._panel, { - command: "update", - timings, - }, "SplashScreen"); - } - } - - public updateSyncDetails(details: SyncDetails) { - this._syncDetails = details; - if (this._panel && !this._panel.webview) { - console.log( - "[SplashScreen] Panel exists but webview is disposed, skipping sync update" - ); - return; - } - if (this._panel) { - safePostMessageToPanel(this._panel, { - command: "syncUpdate", - syncDetails: details, - }, "SplashScreen"); - } - } - - public markComplete() { - debug("[SplashScreen] markComplete() called"); - if (this._panel) { - // Send message to the webview that loading is complete - safePostMessageToPanel(this._panel, { - command: "complete", - }, "SplashScreen"); - debug("[SplashScreen] Sent 'complete' message to webview"); - } else { - debug("[SplashScreen] No panel to mark complete"); - } - } - - public close() { - this._panel?.dispose(); - } - - public get panel(): vscode.WebviewPanel | undefined { - return this._panel; - } - - private _updateWebview() { - if (!this._panel) return; - this._panel.webview.html = this._getHtmlForWebview(); - } - - private _getHtmlForWebview(): string { - const webview = this._panel!.webview; - - return getWebviewHtml(webview, { extensionUri: this._extensionUri } as vscode.ExtensionContext, { - title: "Codex Editor Loading", - scriptPath: ["SplashScreen", "index.js"], - csp: `default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline'; script-src 'nonce-\${nonce}' 'strict-dynamic' https://static.cloudflareinsights.com; worker-src ${webview.cspSource} blob:; connect-src https://*.vscode-cdn.net https://*.frontierrnd.com; img-src ${webview.cspSource} https: data:; font-src ${webview.cspSource}; media-src ${webview.cspSource} https: blob:;`, - initialData: { timings: this._timings, syncDetails: this._syncDetails }, - inlineStyles: ` - body { margin: 0; padding: 0; height: 100vh; width: 100vw; overflow: hidden; background-color: var(--vscode-editor-background); color: var(--vscode-foreground); font-family: var(--vscode-font-family); } - #root { height: 100%; width: 100%; } - `, - customScript: ` - window.addEventListener('message', event => { - const message = event.data; - if (message) { - const customEvent = new CustomEvent('vscode-message', { detail: message }); - document.getElementById('root').dispatchEvent(customEvent); - } - }); - window.addEventListener('animation-complete', () => { - // The React component will handle vscode.postMessage through the shared API - console.log('Animation complete event received'); - }); - ` - }); - } -} diff --git a/src/providers/SplashScreen/index.ts b/src/providers/SplashScreen/index.ts deleted file mode 100644 index 814f38c2e..000000000 --- a/src/providers/SplashScreen/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./SplashScreenProvider"; -export * from "./register"; diff --git a/src/providers/SplashScreen/register.ts b/src/providers/SplashScreen/register.ts deleted file mode 100644 index 1ae312afe..000000000 --- a/src/providers/SplashScreen/register.ts +++ /dev/null @@ -1,67 +0,0 @@ -import * as vscode from "vscode"; -import { SplashScreenProvider } from "./SplashScreenProvider"; -import { ActivationTiming } from "../../extension"; - -let splashScreenProvider: SplashScreenProvider | undefined; -let splashScreenTimer: NodeJS.Timeout | undefined; - -export function registerSplashScreenProvider(context: vscode.ExtensionContext) { - splashScreenProvider = new SplashScreenProvider(context.extensionUri); - context.subscriptions.push(splashScreenProvider); -} - -export async function showSplashScreen(activationStart: number) { - if (!splashScreenProvider) { - console.error("[SplashScreen] Provider not registered"); - return; - } - - await splashScreenProvider.show(activationStart); - - // Keep the splash screen focused by periodically checking - splashScreenTimer = setInterval(() => { - if (splashScreenProvider?.panel?.visible) { - // Ensure the splash screen stays visible and in focus - splashScreenProvider.panel.reveal(vscode.ViewColumn.One, true); - } else { - // Stop checking if panel is gone - if (splashScreenTimer) { - clearInterval(splashScreenTimer); - splashScreenTimer = undefined; - } - } - }, 500) as unknown as NodeJS.Timeout; -} - -export function updateSplashScreenTimings(timings: ActivationTiming[]) { - if (splashScreenProvider) { - splashScreenProvider.updateTimings(timings); - } -} - -export function updateSplashScreenSync(progress: number, message: string, currentFile?: string) { - if (splashScreenProvider) { - splashScreenProvider.updateSyncDetails({ progress, message, currentFile }); - } -} - -export function closeSplashScreen(callback?: () => void | Promise) { - if (splashScreenTimer) { - clearInterval(splashScreenTimer); - splashScreenTimer = undefined; - } - - if (splashScreenProvider) { - splashScreenProvider.markComplete(); - - // Give the animation time to complete before closing - setTimeout(async () => { - splashScreenProvider?.close(); - if (callback) { - await callback(); - } - }, 1500); - } else if (callback) { - callback(); - } -} diff --git a/src/providers/StartupFlow/StartupFlowProvider.ts b/src/providers/StartupFlow/StartupFlowProvider.ts index baea57bde..70afd17c7 100644 --- a/src/providers/StartupFlow/StartupFlowProvider.ts +++ b/src/providers/StartupFlow/StartupFlowProvider.ts @@ -3,6 +3,7 @@ import { MessagesFromStartupFlowProvider, GitLabProject, ProjectWithSyncStatus, + ProjectSyncStatus, LocalProject, ProjectManagerMessageFromWebview, ProjectMetadata, @@ -221,6 +222,26 @@ export class StartupFlowProvider implements vscode.CustomTextEditorProvider { webviewPanel: vscode.WebviewPanel, _token: vscode.CancellationToken ): void | Thenable { + // Don't show startup flow if there are other tabs open (unless explicitly forced) + // This prevents the startup flow from appearing during session restore + const hasOtherTabs = vscode.window.tabGroups.all.some((group) => + group.tabs.some(tab => { + // Check if this tab is NOT the startup flow itself + const input = tab.input; + if (input && typeof input === 'object' && 'uri' in input) { + const uri = (input as { uri: vscode.Uri }).uri; + return !uri.scheme.includes('startupFlow'); + } + return true; // Count unknown tabs as "other" tabs + }) + ); + + if (hasOtherTabs && !this._forceLogin) { + debugLog("Other tabs are open - closing startup flow"); + webviewPanel.dispose(); + return; + } + this.webviewPanel = webviewPanel; this.disposables.push(webviewPanel); @@ -1425,11 +1446,16 @@ export class StartupFlowProvider implements vscode.CustomTextEditorProvider { currentUsername?: string | null; } | undefined; + // Separate variable to hold the remote metadata for version checks + let fetchedRemoteMetadata: { meta?: { requiredExtensions?: { codexEditor?: string; frontierAuthentication?: string }; [key: string]: unknown }; [key: string]: unknown } | undefined; try { debugLog("Checking remote update requirement for project:", projectPath); const { checkRemoteProjectRequirements } = await import("../../utils/remoteUpdatingManager"); // Pass true for bypassCache to ensure we verify connectivity before deciding to update - remoteProjectRequirements = await checkRemoteProjectRequirements(projectPath, undefined, true); + const remoteResult = await checkRemoteProjectRequirements(projectPath, undefined, true); + remoteProjectRequirements = remoteResult; + // Capture the remote metadata for version checks (available since remote was fetched) + fetchedRemoteMetadata = (remoteResult as { remoteMetadata?: typeof fetchedRemoteMetadata }).remoteMetadata; if (remoteProjectRequirements.updateRequired) { debugLog("Remote update required for user:", remoteProjectRequirements.currentUsername); @@ -1456,6 +1482,22 @@ export class StartupFlowProvider implements vscode.CustomTextEditorProvider { } if (shouldUpdate) { + // ── Version gate: block update if extensions are outdated ── + // Checks both local and remote metadata requiredExtensions + REQUIRED_FRONTIER_VERSION + try { + const { ensureExtensionVersionsForSwapOrUpdate } = await import("../../utils/versionGate"); + const versionCheck = await ensureExtensionVersionsForSwapOrUpdate(projectPath, { + remoteMetadata: fetchedRemoteMetadata, + operationLabel: "To update the project", + }); + if (!versionCheck.allowed) { + // Modal was already shown – abort the update (and opening) + return; + } + } catch (versionErr) { + debugLog("Version check for update failed (non-fatal, allowing update):", versionErr); + } + remoteUpdateWasPerformed = true; // Inform webview that updating is starting (not opening) @@ -1577,9 +1619,13 @@ export class StartupFlowProvider implements vscode.CustomTextEditorProvider { } const { checkProjectSwapRequired } = await import("../../utils/projectSwapManager"); + // Always bypass cache on project open to get fresh remote data. + // This ensures we detect cancellations, new swaps, and user completions + // that happened since we last checked. const localSwapCheck = await checkProjectSwapRequired( projectPath, - remoteProjectRequirements?.currentUsername || undefined + remoteProjectRequirements?.currentUsername || undefined, + true // bypassCache: always check remote on project open ); // Use Awaited to get the return type of checkProjectSwapRequired type SwapCheckResult = Awaited>; @@ -1589,6 +1635,32 @@ export class StartupFlowProvider implements vscode.CustomTextEditorProvider { : { required: true, reason: "Remote swap required", swapInfo: remoteProjectRequirements.swapInfo, activeEntry: localSwapCheck.activeEntry }) : localSwapCheck; + // If swap is not required and we have merged data from remote, + // update local metadata.json with the authoritative swap status. + // This ensures local metadata reflects cancellations from remote. + if (!swapCheck.required && swapCheck.swapInfo && localSwapCheck.swapInfo) { + try { + const { sortSwapEntries, orderEntryFields } = await import("../../utils/projectSwapManager"); + const projectUri = vscode.Uri.file(projectPath); + await MetadataManager.safeUpdateMetadata( + projectUri, + (meta) => { + if (!meta.meta) { + meta.meta = {} as any; + } + const sorted = sortSwapEntries(localSwapCheck.swapInfo!.swapEntries || []); + meta.meta!.projectSwap = { + swapEntries: sorted.map(orderEntryFields), + }; + return meta; + } + ); + debugLog("Updated local metadata.json with merged swap status from remote"); + } catch (metaUpdateErr) { + debugLog("Failed to update local metadata with remote swap status (non-fatal):", metaUpdateErr); + } + } + if (swapCheck.userAlreadySwapped && swapCheck.activeEntry) { const activeEntry = swapCheck.activeEntry; const swapTargetLabel = @@ -1713,6 +1785,24 @@ export class StartupFlowProvider implements vscode.CustomTextEditorProvider { if (swapCheck.required && swapCheck.activeEntry && !skipSwapPrompt) { debugLog("Project swap required for project"); + // If the remote server is unreachable, we can't perform the swap. + // Show a modal and let the user open the project offline. + if (localSwapCheck.remoteUnreachable) { + const activeEntry = swapCheck.activeEntry; + const newProjectName = activeEntry.newProjectName || activeEntry.newProjectUrl || "the new project"; + const offlineChoice = await vscode.window.showWarningMessage( + `Server Unreachable\n\n` + + `A project swap to "${newProjectName}" has been requested, but the server cannot be reached at this time.\n\n` + + `You can open this project and work offline, but the swap cannot be performed until the server is available again. ` + + `It may be best to wait for the server to come back up or for your internet connection to be restored.`, + { modal: true }, + "Open Project Offline", + ); + if (offlineChoice !== "Open Project Offline") { + return; // User cancelled + } + // Fall through to normal project open (skip swap) + } else { const activeEntry = swapCheck.activeEntry; const newProjectUrl = activeEntry.newProjectUrl; const newProjectName = activeEntry.newProjectName; @@ -1729,6 +1819,61 @@ export class StartupFlowProvider implements vscode.CustomTextEditorProvider { if (swapDecision === "openDeprecated") { // Continue opening without swapping } else { + // Re-validate swap is still active before executing + // (it could have been cancelled by another user since we checked) + try { + const recheck = await checkProjectSwapRequired(projectPath, remoteProjectRequirements?.currentUsername || undefined, true); + if (recheck.remoteUnreachable) { + debugLog("Server unreachable during re-validation - cannot perform swap"); + await vscode.window.showWarningMessage( + "Server Unreachable\n\n" + + "The swap cannot be performed because the server is not reachable. " + + "Please check your internet connection or try again later.\n\n" + + "Opening project without swapping.", + { modal: true }, + "OK" + ); + break; // Fall through to normal open + } + if (!recheck.required || !recheck.activeEntry || recheck.activeEntry.swapUUID !== swapUUID) { + debugLog("Swap no longer required (cancelled or changed since check) - aborting swap"); + + // Update local metadata with merged data + if (recheck.swapInfo) { + try { + const { sortSwapEntries: sortEntries, orderEntryFields: orderFields } = await import("../../utils/projectSwapManager"); + await MetadataManager.safeUpdateMetadata( + vscode.Uri.file(projectPath), + (meta) => { + if (!meta.meta) { meta.meta = {} as any; } + const sorted = sortEntries(recheck.swapInfo!.swapEntries || []); + meta.meta!.projectSwap = { swapEntries: sorted.map(orderFields) }; + return meta; + } + ); + } catch { /* non-fatal */ } + } + + // Clean up localProjectSwap.json + try { + const { deleteLocalProjectSwapFile } = await import("../../utils/localProjectSettings"); + await deleteLocalProjectSwapFile(vscode.Uri.file(projectPath)); + } catch { /* non-fatal */ } + + await vscode.window.showWarningMessage( + "Swap Cancelled\n\n" + + "The project swap has been cancelled or is no longer required.\n\n" + + "Opening the project normally.", + { modal: true }, + "OK" + ); + // Fall through to normal open + break; + } + } catch (recheckErr) { + debugLog("Failed to re-validate swap (proceeding anyway):", recheckErr); + } + swapWasPerformed = true; // Show notification and perform swap @@ -1775,6 +1920,7 @@ export class StartupFlowProvider implements vscode.CustomTextEditorProvider { } } } + } // end else (server reachable) } } catch (swapErr) { debugLog("Project swap check/execution failed:", swapErr); @@ -2477,6 +2623,13 @@ export class StartupFlowProvider implements vscode.CustomTextEditorProvider { // Auth extension might not be installed, ignore silently debugLog("Could not notify auth extension of connectivity restored:", error); } + // CRITICAL: After auth revalidation, refresh the project list. + // Without this, stale "serverUnreachable" or incorrectly-set "orphaned" + // statuses persist until the user manually refreshes. + if (this.webviewPanel) { + debugLog("Connectivity restored - refreshing project list"); + await this.sendList(this.webviewPanel); + } break; case "extension.installFrontier": debugLog("Opening extensions view"); @@ -2542,12 +2695,16 @@ export class StartupFlowProvider implements vscode.CustomTextEditorProvider { ? Object.keys(metadata.ingredients) : []; + // Get chat system message if it exists + const chatSystemMessage = metadata.chatSystemMessage; + this.safeSendMessage({ command: "metadata.checkResponse", data: { sourceLanguage, targetLanguage, sourceTexts, + chatSystemMessage, }, }); } catch (error) { @@ -2558,12 +2715,139 @@ export class StartupFlowProvider implements vscode.CustomTextEditorProvider { sourceLanguage: null, targetLanguage: null, sourceTexts: [], + chatSystemMessage: null, }, }); } } break; } + case "systemMessage.generate": { + debugLog("Generating system message"); + try { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders) { + this.safeSendMessage({ + command: "systemMessage.generateError", + error: "No workspace folder found", + }); + break; + } + + // Get source and target languages from metadata + const metadataUri = vscode.Uri.joinPath( + workspaceFolders[0].uri, + "metadata.json" + ); + const metadataContent = await vscode.workspace.fs.readFile(metadataUri); + const metadata = JSON.parse(metadataContent.toString()); + + const sourceLanguage = metadata.languages?.find( + (l: any) => l.projectStatus === "source" + ); + const targetLanguage = metadata.languages?.find( + (l: any) => l.projectStatus === "target" + ); + + if (!sourceLanguage || !targetLanguage) { + this.safeSendMessage({ + command: "systemMessage.generateError", + error: "Source and target languages must be set before generating system message", + }); + break; + } + + // Import and call the generation function + const { generateChatSystemMessage } = await import("../../copilotSettings/copilotSettings"); + const generatedMessage = await generateChatSystemMessage( + sourceLanguage, + targetLanguage, + workspaceFolders[0].uri + ); + + if (generatedMessage) { + // Save the generated message to metadata.json immediately + const { MetadataManager } = await import("../../utils/metadataManager"); + const saveResult = await MetadataManager.setChatSystemMessage( + generatedMessage, + workspaceFolders[0].uri + ); + + if (saveResult.success) { + this.safeSendMessage({ + command: "systemMessage.generated", + message: generatedMessage, + }); + } else { + // Still send the generated message even if save fails + // User can manually save it later + this.safeSendMessage({ + command: "systemMessage.generated", + message: generatedMessage, + }); + console.warn("Generated system message but failed to save:", saveResult.error); + } + } else { + this.safeSendMessage({ + command: "systemMessage.generateError", + error: "Failed to generate system message. Please check your API configuration.", + }); + } + } catch (error) { + console.error("Error generating system message:", error); + this.safeSendMessage({ + command: "systemMessage.generateError", + error: error instanceof Error ? error.message : "Failed to generate system message", + }); + } + break; + } + case "systemMessage.save": { + debugLog("Saving system message"); + try { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders) { + this.safeSendMessage({ + command: "systemMessage.saveError", + error: "No workspace folder found", + }); + break; + } + + // Type guard to ensure message has the 'message' property + if (!("message" in message) || typeof message.message !== "string") { + this.safeSendMessage({ + command: "systemMessage.saveError", + error: "Invalid message format", + }); + break; + } + + const { MetadataManager } = await import("../../utils/metadataManager"); + const saveResult = await MetadataManager.setChatSystemMessage( + message.message, + workspaceFolders[0].uri + ); + + if (saveResult.success) { + this.safeSendMessage({ + command: "systemMessage.saved", + }); + } else { + this.safeSendMessage({ + command: "systemMessage.saveError", + error: saveResult.error || "Failed to save system message", + }); + } + } catch (error) { + console.error("Error saving system message:", error); + this.safeSendMessage({ + command: "systemMessage.saveError", + error: error instanceof Error ? error.message : "Failed to save system message", + }); + } + break; + } case "getProjectsListFromGitLab": { debugLog("Fetching GitLab projects list"); await this.sendList(this.webviewPanel!); @@ -3245,6 +3529,13 @@ export class StartupFlowProvider implements vscode.CustomTextEditorProvider { return; } + // Notify webview that fix operation is in progress (locks UI) + this.safeSendMessage({ + command: "project.fixingInProgress", + projectPath, + fixing: true, + } as any); + try { const projectUri = vscode.Uri.file(projectPath); const gitPath = vscode.Uri.joinPath(projectUri, ".git"); @@ -3444,6 +3735,13 @@ export class StartupFlowProvider implements vscode.CustomTextEditorProvider { } catch (error) { console.error("Error fixing project:", error); vscode.window.showErrorMessage(`Failed to fix project: ${error instanceof Error ? error.message : String(error)}`); + } finally { + // Always unlock the UI, regardless of outcome (success, cancel, error) + this.safeSendMessage({ + command: "project.fixingInProgress", + projectPath, + fixing: false, + } as any); } break; } @@ -3454,66 +3752,167 @@ export class StartupFlowProvider implements vscode.CustomTextEditorProvider { return; } + // Notify webview that swap operation is in progress (locks UI) + this.safeSendMessage({ + command: "project.swappingInProgress", + projectPath, + swapping: true, + } as any); + try { + // ── Version gate: block swap if extensions are outdated ── + // Checks old project's local + remote metadata requiredExtensions + REQUIRED_FRONTIER_VERSION + try { + const { ensureExtensionVersionsForSwapOrUpdate } = await import("../../utils/versionGate"); + + // Fetch the old project's remote metadata for version comparison + let oldProjectRemoteMetadata: ProjectMetadata | undefined; + try { + const { extractProjectIdFromUrl, fetchRemoteMetadata } = await import("../../utils/remoteUpdatingManager"); + const gitModule = await import("isomorphic-git"); + const fsModule = await import("fs"); + const remotes = await gitModule.listRemotes({ fs: fsModule, dir: projectPath }); + const origin = remotes.find((r) => r.remote === "origin"); + if (origin?.url) { + const projId = extractProjectIdFromUrl(origin.url); + if (projId) { + const remoteMeta = await fetchRemoteMetadata(projId, false); + if (remoteMeta) { + oldProjectRemoteMetadata = remoteMeta as ProjectMetadata; + } + } + } + } catch (remoteFetchErr) { + debugLog("Failed to fetch old project remote metadata for version check (non-fatal):", remoteFetchErr); + } + + const versionCheck = await ensureExtensionVersionsForSwapOrUpdate(projectPath, { + remoteMetadata: oldProjectRemoteMetadata, + operationLabel: "To swap the project", + }); + if (!versionCheck.allowed) { + // Modal was already shown – unlock UI and abort + this.safeSendMessage({ + command: "project.swappingInProgress", + projectPath, + swapping: false, + } as any); + return; + } + } catch (versionErr) { + debugLog("Version check failed (non-fatal, allowing swap):", versionErr); + } + const projectUri = vscode.Uri.file(projectPath); - const metadataResult = await MetadataManager.safeReadMetadata(projectUri); - const { normalizeProjectSwapInfo, getActiveSwapEntry, findSwapEntryByUUID } = await import("../../utils/projectSwapManager"); - const { readLocalProjectSwapFile } = await import("../../utils/localProjectSettings"); - // Gather swap info from all sources - const metadataSwapInfo = metadataResult.metadata?.meta?.projectSwap; - let localSwapFileInfo: ProjectSwapInfo | undefined; - try { - const localSwapFile = await readLocalProjectSwapFile(projectUri); - if (localSwapFile?.remoteSwapInfo) { - localSwapFileInfo = localSwapFile.remoteSwapInfo; + // Use checkProjectSwapRequired for authoritative swap status. + // This fetches remote, merges all sources, applies "cancelled is sticky", + // and cleans up localProjectSwap.json when appropriate. + const { checkProjectSwapRequired, normalizeProjectSwapInfo, getActiveSwapEntry, findSwapEntryByUUID } = await import("../../utils/projectSwapManager"); + const swapResult = await checkProjectSwapRequired(projectPath, undefined, true); + + if (swapResult.remoteUnreachable && swapResult.required) { + // Server unreachable - can't perform swap, but let user open offline. + const offlineChoice = await vscode.window.showWarningMessage( + "Server Unreachable\n\n" + + "A project swap has been requested, but the swap requires an internet connection.\n\n" + + "You can open this project and work offline. The swap will be available when connectivity is restored.", + { modal: true }, + "Open Project Offline" + ); + if (offlineChoice === "Open Project Offline") { + await MetadataManager.safeOpenFolder(projectUri); } - } catch { - // Non-fatal + return; } - // MERGE entries from all sources by swapUUID, keeping the most recent swapModifiedAt - // This ensures we use cancelled status if it's more recent than active status - const metadataEntries = metadataSwapInfo ? (normalizeProjectSwapInfo(metadataSwapInfo).swapEntries || []) : []; - const localSwapEntries = localSwapFileInfo ? (normalizeProjectSwapInfo(localSwapFileInfo).swapEntries || []) : []; + if (swapResult.userAlreadySwapped && swapResult.activeEntry) { + // User has already completed this swap (detected from NEW project's remote). + // writeUserSwapCompletionToOldProject was already called inside checkProjectSwapRequired. + const swapTargetLabel = + swapResult.activeEntry.newProjectName || swapResult.activeEntry.newProjectUrl || "the new project"; + const alreadySwappedChoice = await vscode.window.showWarningMessage( + `Already Swapped\n\n` + + `You have already swapped to ${swapTargetLabel}.\n\n` + + "You can open this deprecated project, delete the local copy, or cancel.", + { modal: true }, + "Open Project", + "Delete Local Project" + ); - const mergedEntriesMap = new Map(); - const addOrUpdateEntry = (entry: ProjectSwapEntry) => { - const key = entry.swapUUID; - const existing = mergedEntriesMap.get(key); - if (!existing) { - mergedEntriesMap.set(key, entry); - } else { - // Compare swapModifiedAt timestamps - use the more recent one - const existingModified = existing.swapModifiedAt ?? existing.swapInitiatedAt; - const newModified = entry.swapModifiedAt ?? entry.swapInitiatedAt; - if (newModified > existingModified) { - mergedEntriesMap.set(key, entry); - } + if (alreadySwappedChoice === "Delete Local Project") { + const projectName = path.basename(projectPath); + await this.performProjectDeletion(projectPath, projectName); + return; } - }; - for (const entry of metadataEntries) { addOrUpdateEntry(entry); } - for (const entry of localSwapEntries) { addOrUpdateEntry(entry); } + if (alreadySwappedChoice === "Open Project") { + await MetadataManager.safeOpenFolder(projectUri); + } - const mergedEntries = Array.from(mergedEntriesMap.values()); - const effectiveSwapInfo: ProjectSwapInfo | undefined = mergedEntries.length > 0 - ? { swapEntries: mergedEntries } - : (localSwapFileInfo || metadataSwapInfo); + // Refresh the project list to remove the swap banner + if (this.webviewPanel) { + this.sendList(this.webviewPanel); + } + return; + } + + if (!swapResult.required || !swapResult.activeEntry) { + // Swap no longer required (cancelled, completed, or erased). + // Update local metadata.json with the merged/authoritative swap data + // and clean up localProjectSwap.json. + if (swapResult.swapInfo) { + try { + const { sortSwapEntries, orderEntryFields } = await import("../../utils/projectSwapManager"); + await MetadataManager.safeUpdateMetadata( + projectUri, + (meta) => { + if (!meta.meta) { + meta.meta = {} as any; + } + // Write the merged entries (with cancelled status, cancellation details, etc.) + const sorted = sortSwapEntries(swapResult.swapInfo!.swapEntries || []); + meta.meta!.projectSwap = { + swapEntries: sorted.map(orderEntryFields), + }; + return meta; + } + ); + debugLog("Updated local metadata.json with authoritative swap status (cancelled)"); + } catch (metaErr) { + debugLog("Failed to update local metadata.json (non-fatal):", metaErr); + } + } - if (!effectiveSwapInfo) { - vscode.window.showErrorMessage("Cannot perform swap: Project swap metadata missing."); + // Delete localProjectSwap.json - no longer needed + try { + const { deleteLocalProjectSwapFile } = await import("../../utils/localProjectSettings"); + await deleteLocalProjectSwapFile(projectUri); + debugLog("Deleted localProjectSwap.json (swap no longer active)"); + } catch { + // Non-fatal - file may not exist + } + + // Show modal so user can open the project + const choice = await vscode.window.showWarningMessage( + "Swap Cancelled\n\n" + + "The project swap has been cancelled or is no longer required.\n\n" + + "You can open this project normally.", + { modal: true }, + "Open Project" + ); + if (choice === "Open Project") { + await MetadataManager.safeOpenFolder(projectUri); + } return; } - const swapInfo = effectiveSwapInfo; + const activeEntry = swapResult.activeEntry; + const swapInfo = swapResult.swapInfo!; + const metadataResult = await MetadataManager.safeReadMetadata(projectUri); const projectName = metadataResult.metadata?.projectName || path.basename(projectPath); - // Normalize swap info to get active entry - const normalizedSwap = normalizeProjectSwapInfo(swapInfo); - const activeEntry = getActiveSwapEntry(normalizedSwap); - - if (!activeEntry?.newProjectUrl) { + if (!activeEntry.newProjectUrl) { vscode.window.showErrorMessage("Cannot perform swap: No target project URL found."); return; } @@ -3526,6 +3925,28 @@ export class StartupFlowProvider implements vscode.CustomTextEditorProvider { if (projectId) { const currentUsername = await getCurrentUsername(); const remoteMetadata = await fetchRemoteMetadata(projectId, false); + + // ── Version gate: also check the NEW project's remote requirements ── + if (remoteMetadata) { + try { + const { ensureExtensionVersionsForSwapOrUpdate } = await import("../../utils/versionGate"); + const remoteVersionCheck = await ensureExtensionVersionsForSwapOrUpdate(projectPath, { + remoteMetadata, + operationLabel: "To swap the project", + }); + if (!remoteVersionCheck.allowed) { + this.safeSendMessage({ + command: "project.swappingInProgress", + projectPath, + swapping: false, + } as any); + return; + } + } catch (remoteVersionErr) { + debugLog("Remote version check failed (non-fatal):", remoteVersionErr); + } + } + const remoteSwap = remoteMetadata?.meta?.projectSwap; if (remoteSwap) { // Find matching entry by swapUUID @@ -3699,6 +4120,72 @@ export class StartupFlowProvider implements vscode.CustomTextEditorProvider { // Fall through to continue with swap below } + // Re-validate swap is still active before executing + try { + const { checkProjectSwapRequired: recheckSwap } = await import("../../utils/projectSwapManager"); + const recheck = await recheckSwap(projectPath, undefined, true); + if (recheck.remoteUnreachable) { + debugLog("Server unreachable during re-validation - cannot perform swap"); + vscode.window.showWarningMessage( + "The swap cannot be performed because the server is not reachable. " + + "Please check your internet connection or try again later." + ); + return; + } + if (recheck.userAlreadySwapped && recheck.activeEntry) { + debugLog("User already completed this swap during re-validation"); + const swapTargetLabel = + recheck.activeEntry.newProjectName || recheck.activeEntry.newProjectUrl || "the new project"; + await vscode.window.showWarningMessage( + `Already Swapped\n\n` + + `You have already swapped to ${swapTargetLabel}.\n\n` + + `This project is deprecated but can still be opened.`, + { modal: true }, + "Open Project" + ); + // Refresh the project list to remove the swap banner + if (this.webviewPanel) { + this.sendList(this.webviewPanel); + } + return; + } + if (!recheck.required || !recheck.activeEntry || recheck.activeEntry.swapUUID !== swapUUID) { + debugLog("Swap no longer required (cancelled or changed) - aborting"); + + // Update local metadata with merged data + if (recheck.swapInfo) { + try { + const { sortSwapEntries: sortRecheck, orderEntryFields: orderRecheck } = await import("../../utils/projectSwapManager"); + await MetadataManager.safeUpdateMetadata( + projectUri, + (meta) => { + if (!meta.meta) { meta.meta = {} as any; } + const sorted = sortRecheck(recheck.swapInfo!.swapEntries || []); + meta.meta!.projectSwap = { swapEntries: sorted.map(orderRecheck) }; + return meta; + } + ); + } catch { /* non-fatal */ } + } + + // Clean up localProjectSwap.json + try { + const { deleteLocalProjectSwapFile } = await import("../../utils/localProjectSettings"); + await deleteLocalProjectSwapFile(projectUri); + } catch { /* non-fatal */ } + + await vscode.window.showWarningMessage( + "Swap Cancelled\n\n" + + "The project swap has been cancelled or is no longer required.", + { modal: true }, + "Open Project" + ); + return; + } + } catch (recheckErr) { + debugLog("Failed to re-validate swap (proceeding anyway):", recheckErr); + } + // No downloads needed or prerequisites met - proceed with swap await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, @@ -3731,6 +4218,13 @@ export class StartupFlowProvider implements vscode.CustomTextEditorProvider { `Project swap failed.\n\nThe old project has been backed up to the "archived_projects" folder. ` + `Please contact your project administrator for assistance.\n\nError: ${error instanceof Error ? error.message : String(error)}` ); + } finally { + // Always unlock the UI, regardless of outcome (success, cancel, error) + this.safeSendMessage({ + command: "project.swappingInProgress", + projectPath, + swapping: false, + } as any); } break; } @@ -4883,16 +5377,20 @@ export class StartupFlowProvider implements vscode.CustomTextEditorProvider { console.error("Error fetching local projects:", localProjectsResult.reason); } - const remoteProjects = - remoteProjectsResult.status === "fulfilled" ? remoteProjectsResult.value : []; + const remoteResult = + remoteProjectsResult.status === "fulfilled" + ? remoteProjectsResult.value + : { projects: [] as GitLabProject[], serverUnreachable: true }; if (remoteProjectsResult.status === "rejected") { console.error("Error fetching remote projects:", remoteProjectsResult.reason); } + const remoteProjects = remoteResult.projects; + const remoteServerUnreachable = remoteResult.serverUnreachable; const projectList: ProjectWithSyncStatus[] = []; - // Process remote projects + // Process remote projects (only if server was reachable) for (const project of remoteProjects) { projectList.push({ name: project.name, @@ -4966,12 +5464,26 @@ export class StartupFlowProvider implements vscode.CustomTextEditorProvider { syncStatus: "downloadedAndSynced", }; } else { - // If local project has a git remote but is NOT in the remote list, - // mark it as "orphaned" (remote missing or inaccessible). - // If no git remote, it's a pure local project. + // Local project has a git remote but is NOT in the remote list. + // CRITICAL: Distinguish between "server unreachable" and "project genuinely missing". + // If the server was unreachable (network error, expired cert, 500, etc.), + // we must NOT mark projects as orphaned -- that would trigger destructive + // actions like "Fix & Open" which deletes .git and renames folders. + let status: ProjectSyncStatus; + if (!project.gitOriginUrl) { + status = "localOnlyNotSynced"; + } else if (remoteServerUnreachable) { + // Server was unreachable -- don't mark as orphaned. + // Use "serverUnreachable" so the UI shows appropriate messaging. + status = "serverUnreachable"; + } else { + // Server responded successfully but this project was not in the list. + // This means the remote project was genuinely deleted/missing. + status = "orphaned"; + } projectList.push({ ...project, - syncStatus: project.gitOriginUrl ? "orphaned" : "localOnlyNotSynced", + syncStatus: status, }); } } @@ -4987,9 +5499,10 @@ export class StartupFlowProvider implements vscode.CustomTextEditorProvider { ); // Build set of local project URLs (projects that are downloaded) + // Include "serverUnreachable" since these are also local projects (just can't verify remote) const localDownloadedUrls = new Set( projectList - .filter(p => p.syncStatus === "downloadedAndSynced" || p.syncStatus === "localOnlyNotSynced" || p.syncStatus === "orphaned") + .filter(p => p.syncStatus === "downloadedAndSynced" || p.syncStatus === "localOnlyNotSynced" || p.syncStatus === "orphaned" || p.syncStatus === "serverUnreachable") .map(p => normalizeUrl(p.gitOriginUrl)) .filter(Boolean) ); @@ -4999,8 +5512,9 @@ export class StartupFlowProvider implements vscode.CustomTextEditorProvider { // RULE 1: Hide OLD remote projects when NEW is local AND swap is ACTIVE // (User should complete swap on the new project, not clone the old one) // - // RULE 2: Hide NEW remote projects when OLD is local AND swap is ACTIVE - // (User needs to complete swap from old→new, not clone new separately) + // RULE 2: Hide NEW projects (remote OR local) when OLD is local AND swap is ACTIVE + // (User needs to complete swap from old→new, not open new separately + // which could cause them to skip the swap and leave unmerged work behind) // // RULE 3: When swap is COMPLETED/CANCELLED, show BOTH projects // (User may want access to both versions) @@ -5032,13 +5546,15 @@ export class StartupFlowProvider implements vscode.CustomTextEditorProvider { continue; } - // RULE 1: LOCAL OLD project with ACTIVE swap → hide NEW remote + // RULE 1: LOCAL OLD project with ACTIVE swap → hide NEW project (remote OR local) // isOldProject must be explicitly true (not undefined or false) + // Hiding the new project forces users through the swap flow on the old project, + // preventing them from opening the new project and skipping the swap if (activeEntry.isOldProject === true && activeEntry.newProjectUrl) { const newProjectNormalized = normalizeUrl(activeEntry.newProjectUrl); if (newProjectNormalized) { newProjectUrlsToHide.add(newProjectNormalized); - debugLog(`Swap filter: hiding NEW remote ${newProjectNormalized} (OLD local ${project.name} has active swap)`); + debugLog(`Swap filter: hiding NEW project ${newProjectNormalized} (OLD local ${project.name} has active swap)`); } // Also check: if NEW project is already local, hide OLD remote too @@ -5069,28 +5585,31 @@ export class StartupFlowProvider implements vscode.CustomTextEditorProvider { } } - // Apply filtering: only hide REMOTE (cloudOnlyNotSynced) projects - // Local projects are NEVER hidden by swap rules + // Apply filtering: + // - Hide NEW projects (remote OR local) when OLD is local with active swap (RULE 2) + // - Hide OLD remote projects when NEW is local with active swap (RULE 1) + // - Local OLD projects are never hidden (they carry the swap banner) const filteredProjectList = projectList.filter(project => { - // Keep all local projects - they should always be visible - if (project.syncStatus !== "cloudOnlyNotSynced") { - return true; - } - const projectUrlNormalized = normalizeUrl(project.gitOriginUrl); if (!projectUrlNormalized) { return true; // Can't filter without URL, keep it } - // Hide remote OLD projects per RULE 1 & 2 - if (oldProjectUrlsToHide.has(projectUrlNormalized)) { - debugLog(`Swap filter: filtering out remote OLD project ${project.name}`); + // Hide NEW projects (remote OR local) when OLD is local with active swap + // This forces users through the swap flow rather than opening the new project directly + if (newProjectUrlsToHide.has(projectUrlNormalized)) { + debugLog(`Swap filter: filtering out NEW project ${project.name} (syncStatus: ${project.syncStatus})`); return false; } - // Hide remote NEW projects per RULE 2 - if (newProjectUrlsToHide.has(projectUrlNormalized)) { - debugLog(`Swap filter: filtering out remote NEW project ${project.name}`); + // For local projects: keep all remaining (only NEW local projects are hidden above) + if (project.syncStatus !== "cloudOnlyNotSynced") { + return true; + } + + // Hide remote OLD projects per RULE 1 + if (oldProjectUrlsToHide.has(projectUrlNormalized)) { + debugLog(`Swap filter: filtering out remote OLD project ${project.name}`); return false; } @@ -5137,18 +5656,41 @@ export class StartupFlowProvider implements vscode.CustomTextEditorProvider { // Best effort - continue without username } + // Mark projects where the current user has already completed the swap. + // This uses locally persisted data (written by writeUserSwapCompletionToOldProject) + // so it doesn't require any remote API calls. + if (currentUsername) { + const { normalizeProjectSwapInfo, getActiveSwapEntry } = await import("../../utils/projectSwapManager"); + for (const project of filteredProjectList) { + if (!project.projectSwap?.isOldProject || project.projectSwap.swapStatus !== "active") { + continue; + } + // Check the active entry's swappedUsers for the current user + const normalized = normalizeProjectSwapInfo(project.projectSwap); + const activeEntry = getActiveSwapEntry(normalized); + if (activeEntry?.swappedUsers?.some( + (u: ProjectSwapUserEntry) => u.userToSwap === currentUsername && u.executed + )) { + project.projectSwap.currentUserAlreadySwapped = true; + } + } + } + safePostMessageToPanel( webviewPanel, { command: "projectsListFromGitLab", projects: filteredProjectList, currentUsername, + ...(remoteServerUnreachable ? { + error: "Server unreachable - remote project status could not be verified. Some projects may show outdated status." + } : {}), } as MessagesFromStartupFlowProvider, "StartupFlow" ); const mergeTime = Date.now() - startTime; - debugLog(`Complete project list sent in ${mergeTime}ms - Total: ${filteredProjectList.length} projects`); + debugLog(`Complete project list sent in ${mergeTime}ms - Total: ${filteredProjectList.length} projects${remoteServerUnreachable ? " (server unreachable)" : ""}`); } catch (error) { console.error("Failed to fetch and process projects:", error); @@ -5179,6 +5721,10 @@ export class StartupFlowProvider implements vscode.CustomTextEditorProvider { } let projectDir: vscode.Uri | undefined; + // Track swap state for post-clone write-back + let userAlreadySwappedOnClone = false; + let activeSwapEntry: ProjectSwapEntry | undefined; + let cloneUsername: string | undefined; try { // Check remote metadata to warn if this is the old/deprecated project @@ -5191,9 +5737,55 @@ export class StartupFlowProvider implements vscode.CustomTextEditorProvider { const swapInfo = remoteMetadata?.meta?.projectSwap as ProjectSwapInfo | undefined; const normalizedSwapInfo = swapInfo ? normalizeProjectSwapInfo(swapInfo) : undefined; const activeEntry = normalizedSwapInfo ? getActiveSwapEntry(normalizedSwapInfo) : undefined; + activeSwapEntry = activeEntry; // isOldProject is now in each entry, not at the top level if (activeEntry?.isOldProject) { + // Check if the current user has already completed this swap + // by checking the NEW project's remote swappedUsers + if (activeEntry.newProjectUrl) { + try { + const { getCurrentUsername, normalizeSwapUserEntry } = await import("../../utils/remoteUpdatingManager"); + const { findSwapEntryByUUID } = await import("../../utils/projectSwapManager"); + cloneUsername = (await getCurrentUsername()) ?? undefined; + const newProjectId = extractProjectIdFromUrl(activeEntry.newProjectUrl); + if (cloneUsername && newProjectId) { + const newRemoteMeta = await fetchRemoteMetadata(newProjectId, false); + const newRemoteSwap = newRemoteMeta?.meta?.projectSwap; + if (newRemoteSwap) { + const normalizedNew = normalizeProjectSwapInfo(newRemoteSwap); + const matchingEntry = findSwapEntryByUUID(normalizedNew, activeEntry.swapUUID); + const swappedUsers = (matchingEntry?.swappedUsers || []).map( + (u: ProjectSwapUserEntry) => normalizeSwapUserEntry(u) + ); + userAlreadySwappedOnClone = swappedUsers.some( + (u: ProjectSwapUserEntry) => u.userToSwap === cloneUsername && u.executed + ); + } + } + } catch (e) { + debugLog("Failed to check user swap completion on clone (non-fatal):", e); + } + } + + if (userAlreadySwappedOnClone) { + // User already swapped - show informational modal + const swapTargetLabel = activeEntry.newProjectName || activeEntry.newProjectUrl || "the new project"; + const alreadySwappedChoice = await vscode.window.showWarningMessage( + `Already Swapped\n\n` + + `You have already swapped to ${swapTargetLabel}.\n\n` + + `This project is deprecated. You can still clone it if needed.`, + { modal: true }, + "Clone Anyway", + "Cancel" + ); + if (alreadySwappedChoice !== "Clone Anyway") { + return; + } + // User chose to clone anyway - skip the deprecation banner prompt + skipDeprecatedPrompt = true; + } + // ACTIVE swap - this project is currently deprecated const deprecatedMessage = "This project has been deprecated."; @@ -5399,6 +5991,24 @@ export class StartupFlowProvider implements vscode.CustomTextEditorProvider { mediaStrategy ); + // If this is a deprecated project and user already swapped, + // write the completion info to the cloned project's local files. + // This ensures the swap banner doesn't appear for this project. + if (userAlreadySwappedOnClone && activeSwapEntry && cloneUsername) { + try { + const { writeUserSwapCompletionToOldProject } = await import("../../utils/projectSwapManager"); + await writeUserSwapCompletionToOldProject( + projectDir.fsPath, + activeSwapEntry, + cloneUsername, + { swapEntries: activeSwapEntry ? [activeSwapEntry] : [] } + ); + debugLog("Wrote user swap completion to cloned deprecated project"); + } catch (writeErr) { + debugLog("Failed to write user swap completion after clone (non-fatal):", writeErr); + } + } + } finally { // Inform webview that cloning is complete try { @@ -5431,23 +6041,37 @@ export class StartupFlowProvider implements vscode.CustomTextEditorProvider { } /** - * Fetch remote projects with error handling and cleanup + * Fetch remote projects with error handling and cleanup. + * Returns { projects, serverUnreachable } to distinguish between + * "server returned empty list" vs "server could not be reached". */ - private async fetchRemoteProjects(): Promise { + private async fetchRemoteProjects(): Promise<{ + projects: GitLabProject[]; + serverUnreachable: boolean; + }> { if (!this.frontierApi) { - return []; + // No API available -- treat as unreachable (not as "no projects exist") + return { projects: [], serverUnreachable: true }; } try { const remoteProjects = await this.frontierApi.listProjects(false); - // Keep full project names with UUID for proper identification - // (Previously stripped the UUID suffix, but now keeping it for differentiation) + // DEFENSIVE: If the API returned null/undefined instead of an array, + // treat as unreachable. This guards against command execution returning + // unexpected values (e.g., if the auth extension swallows an error). + if (!Array.isArray(remoteProjects)) { + debugLog("listProjects returned non-array value, treating as server unreachable:", typeof remoteProjects); + return { projects: [], serverUnreachable: true }; + } - return remoteProjects; + return { projects: remoteProjects, serverUnreachable: false }; } catch (error) { console.error("Error fetching remote projects:", error); - return []; + // Server error (network failure, expired cert, 500, etc.) + // Return empty list but flag that the server was unreachable. + // This prevents local projects from being incorrectly marked as "orphaned". + return { projects: [], serverUnreachable: true }; } } diff --git a/src/providers/StartupFlow/performProjectSwap.ts b/src/providers/StartupFlow/performProjectSwap.ts index b66d9df6d..47faf164c 100644 --- a/src/providers/StartupFlow/performProjectSwap.ts +++ b/src/providers/StartupFlow/performProjectSwap.ts @@ -1493,7 +1493,7 @@ async function swapDirectories(oldTmpPath: string, newPath: string, targetPath: debugLog("Swapping directories"); if (fs.existsSync(targetPath)) { await archiveExistingTarget(targetPath); - fs.rmSync(targetPath, { recursive: true, force: true }); + await removeDirectoryWithRetries(targetPath); } fs.renameSync(newPath, targetPath); @@ -1503,6 +1503,38 @@ async function swapDirectories(oldTmpPath: string, newPath: string, targetPath: debugLog("Directory swap completed"); } +/** + * Remove a directory with retries to handle race conditions (e.g. ENOTEMPTY + * when another process writes into the directory while it is being deleted). + */ +async function removeDirectoryWithRetries(dirPath: string): Promise { + const maxRetries = 5; + const delays = [100, 300, 600, 1000, 2000]; // ms – increasing back-off + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + fs.rmSync(dirPath, { recursive: true, force: true }); + if (attempt > 0) { + debugLog( + `Successfully removed directory on attempt ${attempt + 1}: ${dirPath}` + ); + } + return; + } catch (error) { + debugLog( + `Attempt ${attempt + 1}/${maxRetries} to remove directory failed: ${dirPath}`, + error + ); + if (attempt < maxRetries - 1) { + await new Promise((resolve) => setTimeout(resolve, delays[attempt])); + } else { + // Final attempt failed – propagate the error + throw error; + } + } + } +} + /** * Clean up the old _tmp folder with retries * This folder contains the old project after it was renamed during swap diff --git a/src/providers/StartupFlow/preflight.ts b/src/providers/StartupFlow/preflight.ts index 3cb738680..a95c11bf5 100644 --- a/src/providers/StartupFlow/preflight.ts +++ b/src/providers/StartupFlow/preflight.ts @@ -1,9 +1,8 @@ import * as vscode from "vscode"; -import { waitForExtensionActivation } from "../../utils/vscode"; import { FrontierAPI } from "../../../webviews/codex-webviews/src/StartupFlow/types"; import git from "isomorphic-git"; import * as fs from "fs"; -import { getAuthApi } from "../../extension"; +import { getAuthApi, waitForAuthInit } from "../../extension"; interface AuthState { isAuthenticated: boolean; @@ -135,6 +134,8 @@ export class PreflightCheck { }; try { + debugLog("Waiting for auth initialization to complete"); + await waitForAuthInit(); debugLog("Checking authentication state"); const isAuthenticated = await this.checkAuthentication(); state.authState.isAuthenticated = isAuthenticated; @@ -252,6 +253,14 @@ export const registerPreflightCommand = (context: vscode.ExtensionContext) => { "codex-project-manager.preflight", async () => { debugLog("Executing preflight command"); + + // Don't open startup flow if there are any open tabs + const hasOpenTabs = vscode.window.tabGroups.all.some((group) => group.tabs.length > 0); + if (hasOpenTabs) { + debugLog("Tabs are open - skipping startup flow"); + return; + } + const state = await preflightCheck.preflight(); debugLog("Preflight state:", state); @@ -298,7 +307,9 @@ export const registerPreflightCommand = (context: vscode.ExtensionContext) => { disposables.push(preflightCommand); context.subscriptions.push(...disposables); - // Run initial preflight check - debugLog("Running initial preflight check"); - vscode.commands.executeCommand("codex-project-manager.preflight"); + // NOTE: We no longer run preflight automatically at startup. + // With non-blocking startup, auth and other services initialize in the background. + // Running preflight immediately would open the startup flow before auth completes, + // causing a "ghost tab" that opens and immediately closes. + // Users can manually trigger preflight via the command if needed. }; diff --git a/src/providers/WelcomeView/register.ts b/src/providers/WelcomeView/register.ts index 85cf7044c..a0c214fc8 100644 --- a/src/providers/WelcomeView/register.ts +++ b/src/providers/WelcomeView/register.ts @@ -1,5 +1,6 @@ import * as vscode from "vscode"; import { WelcomeViewProvider } from "./welcomeViewProvider"; +import { isStartingUp } from "../../extension"; let provider: WelcomeViewProvider; @@ -84,6 +85,12 @@ export function getWelcomeViewProvider(): WelcomeViewProvider { // Check if there are no visible editors and show welcome view if needed export async function showWelcomeViewIfNeeded() { + // Don't show welcome view during startup/tab restoration to prevent race conditions + if (isStartingUp()) { + debug("[WelcomeView] Startup in progress, skipping welcome view"); + return; + } + // Safety check - if provider is not initialized, log and return if (!provider) { console.warn("[WelcomeView] Provider not initialized yet, skipping welcome view"); diff --git a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts index 1dd4f1e6c..dbec461e5 100644 --- a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts @@ -1442,7 +1442,11 @@ const messageHandlers: Record Promise { const typedEvent = event as Extract; - const { cellId, selectedIndex, selectedContent, testId, testName, selectionTimeMs, variants } = typedEvent.content || {}; + const { cellId, selectedIndex, testId, selectionTimeMs, totalVariants } = typedEvent.content || {}; + // These fields may come from extended payloads but aren't in the strict type + const selectedContent = (typedEvent.content as any)?.selectedContent as string | undefined; + const testName = (typedEvent.content as any)?.testName as string | undefined; + const variants = (typedEvent.content as any)?.variants as string[] | undefined; const variantNames: string[] | undefined = variants; const isRecovery = testName === "Recovery" || (typeof testId === "string" && testId.includes("-recovery-")); diff --git a/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts b/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts index 69f2835c1..827f29e0e 100755 --- a/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts @@ -545,13 +545,10 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider - - + + Codex Cell Editor +