Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions cli/src/__tests__/utils/recent-projects.test.ts
Original file line number Diff line number Diff line change
@@ -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]))
})
})
3 changes: 3 additions & 0 deletions cli/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,9 @@ async function main(): Promise<void> {
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, {
Expand Down
15 changes: 9 additions & 6 deletions cli/src/utils/recent-projects.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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
}

Expand All @@ -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)

Expand Down
Loading