diff --git a/cli/src/__tests__/utils/recent-projects.test.ts b/cli/src/__tests__/utils/recent-projects.test.ts new file mode 100644 index 0000000000..95a957dfba --- /dev/null +++ b/cli/src/__tests__/utils/recent-projects.test.ts @@ -0,0 +1,136 @@ +import '../../../../sdk/test/setup-env' + +import fs from 'fs' +import os from 'os' +import path from 'path' + +import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test' + +import * as configDirModule from '../../utils/config-dir' +import { + clearRecentProjects, + loadRecentProjects, + removeRecentProject, + saveRecentProject, +} from '../../utils/recent-projects' + +describe('cli/utils/recent-projects', () => { + let tempConfigDir: string + let tempProjectsDir: string + let restoreGetConfigDir: () => void + + beforeEach(() => { + tempConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'freebuff-config-test-')) + tempProjectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'freebuff-projects-test-')) + + const spy = spyOn(configDirModule, 'getConfigDir').mockReturnValue(tempConfigDir) + restoreGetConfigDir = () => spy.mockRestore() + }) + + afterEach(() => { + restoreGetConfigDir() + try { + fs.rmSync(tempConfigDir, { recursive: true, force: true }) + fs.rmSync(tempProjectsDir, { recursive: true, force: true }) + } catch { + // Ignore cleanup errors + } + }) + + test('returns empty array when recent-projects.json does not exist', () => { + expect(loadRecentProjects()).toEqual([]) + }) + + test('saves and loads existing project directory', () => { + const projectA = path.join(tempProjectsDir, 'project-a') + fs.mkdirSync(projectA, { recursive: true }) + + saveRecentProject(projectA) + + const loaded = loadRecentProjects() + expect(loaded.length).toBe(1) + expect(loaded[0].path).toBe(path.resolve(projectA)) + }) + + test('canonicalizes paths to prevent duplicate entries', () => { + const projectA = path.join(tempProjectsDir, 'project-a') + fs.mkdirSync(projectA, { recursive: true }) + + // Save with trailing slash + saveRecentProject(projectA + path.sep) + expect(loadRecentProjects().length).toBe(1) + + // Save again without trailing slash + saveRecentProject(projectA) + expect(loadRecentProjects().length).toBe(1) + expect(loadRecentProjects()[0].path).toBe(path.resolve(projectA)) + }) + + test('ignores non-existent project directories', () => { + const nonExistent = path.join(tempProjectsDir, 'does-not-exist') + saveRecentProject(nonExistent) + + expect(loadRecentProjects()).toEqual([]) + }) + + test('removes project by canonical path', () => { + const projectA = path.join(tempProjectsDir, 'project-a') + const projectB = path.join(tempProjectsDir, 'project-b') + fs.mkdirSync(projectA, { recursive: true }) + fs.mkdirSync(projectB, { recursive: true }) + + saveRecentProject(projectA) + saveRecentProject(projectB) + expect(loadRecentProjects().length).toBe(2) + + // Remove projectA with trailing slash + removeRecentProject(projectA + path.sep) + const remaining = loadRecentProjects() + expect(remaining.length).toBe(1) + expect(remaining[0].path).toBe(path.resolve(projectB)) + }) + + test('clears all recent projects', () => { + const projectA = path.join(tempProjectsDir, 'project-a') + fs.mkdirSync(projectA, { recursive: true }) + + saveRecentProject(projectA) + expect(loadRecentProjects().length).toBe(1) + + clearRecentProjects() + expect(loadRecentProjects()).toEqual([]) + }) + + test('filters out projects that have been deleted from disk', () => { + const projectA = path.join(tempProjectsDir, 'project-a') + const projectB = path.join(tempProjectsDir, 'project-b') + fs.mkdirSync(projectA, { recursive: true }) + fs.mkdirSync(projectB, { recursive: true }) + + saveRecentProject(projectA) + saveRecentProject(projectB) + expect(loadRecentProjects().length).toBe(2) + + // Delete projectA from disk + fs.rmSync(projectA, { recursive: true, force: true }) + + const loaded = loadRecentProjects() + expect(loaded.length).toBe(1) + expect(loaded[0].path).toBe(path.resolve(projectB)) + }) + + test('caps recent projects at MAX_RECENT_PROJECTS with newest first', () => { + const projectPaths: string[] = [] + for (let i = 0; i < 12; i++) { + const p = path.join(tempProjectsDir, `project-${i}`) + fs.mkdirSync(p, { recursive: true }) + projectPaths.push(p) + saveRecentProject(p) + } + + const loaded = loadRecentProjects() + expect(loaded.length).toBe(10) + // Most recent (project-11) should be first + expect(loaded[0].path).toBe(path.resolve(projectPaths[11])) + }) +}) diff --git a/cli/src/index.tsx b/cli/src/index.tsx index cae4e380eb..a7e19d8089 100644 --- a/cli/src/index.tsx +++ b/cli/src/index.tsx @@ -234,6 +234,9 @@ async function main(): Promise { const homeDir = os.homedir() const startCwd = process.cwd() const showProjectPicker = shouldShowProjectPicker(startCwd, homeDir) + if (!showProjectPicker) { + saveRecentProject(projectRoot) + } // Requires analytics to be initialized, which is done in initializeApp trackEvent(AnalyticsEvent.APP_LAUNCHED, { diff --git a/cli/src/utils/recent-projects.ts b/cli/src/utils/recent-projects.ts index b7df3de0bc..283010bb63 100644 --- a/cli/src/utils/recent-projects.ts +++ b/cli/src/utils/recent-projects.ts @@ -1,7 +1,7 @@ import fs from 'fs' import path from 'path' -import { getConfigDir } from './auth' +import { getConfigDir } from './config-dir' import { logger } from './logger' const MAX_RECENT_PROJECTS = 10 @@ -88,12 +88,13 @@ export const clearRecentProjects = (): void => { * Remove a specific project from the recent projects list */ export const removeRecentProject = (projectPath: string): void => { + const resolvedPath = path.resolve(projectPath) const recentProjectsPath = getRecentProjectsPath() try { const existingProjects = loadRecentProjects() const filteredProjects = existingProjects.filter( - (p) => p.path !== projectPath, + (p) => path.resolve(p.path) !== resolvedPath, ) fs.writeFileSync( @@ -115,9 +116,11 @@ export const removeRecentProject = (projectPath: string): void => { * Validates that the path exists before saving. */ export const saveRecentProject = (projectPath: string): void => { + const resolvedPath = path.resolve(projectPath) + // Validate path exists before saving - if (!fs.existsSync(projectPath)) { - logger.debug({ projectPath }, 'Skipping save for non-existent project path') + if (!fs.existsSync(resolvedPath)) { + logger.debug({ projectPath: resolvedPath }, 'Skipping save for non-existent project path') return } @@ -134,12 +137,12 @@ export const saveRecentProject = (projectPath: string): void => { // Remove the project if it already exists (we'll add it back at the top) const filteredProjects = existingProjects.filter( - (p) => p.path !== projectPath, + (p) => path.resolve(p.path) !== resolvedPath, ) // Add the new/updated project at the beginning const updatedProjects: RecentProject[] = [ - { path: projectPath, lastOpened: Date.now() }, + { path: resolvedPath, lastOpened: Date.now() }, ...filteredProjects, ].slice(0, MAX_RECENT_PROJECTS)