Skip to content

Commit 3e8851f

Browse files
committed
Auto-save launched project to recent projects and canonicalize paths
1 parent de0954c commit 3e8851f

3 files changed

Lines changed: 148 additions & 6 deletions

File tree

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import '../../../../sdk/test/setup-env'
2+
3+
import fs from 'fs'
4+
import os from 'os'
5+
import path from 'path'
6+
7+
import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test'
8+
9+
import * as configDirModule from '../../utils/config-dir'
10+
import {
11+
clearRecentProjects,
12+
loadRecentProjects,
13+
removeRecentProject,
14+
saveRecentProject,
15+
} from '../../utils/recent-projects'
16+
17+
describe('cli/utils/recent-projects', () => {
18+
let tempConfigDir: string
19+
let tempProjectsDir: string
20+
let restoreGetConfigDir: () => void
21+
22+
beforeEach(() => {
23+
tempConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'freebuff-config-test-'))
24+
tempProjectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'freebuff-projects-test-'))
25+
26+
const spy = spyOn(configDirModule, 'getConfigDir').mockReturnValue(tempConfigDir)
27+
restoreGetConfigDir = () => spy.mockRestore()
28+
})
29+
30+
afterEach(() => {
31+
restoreGetConfigDir()
32+
try {
33+
fs.rmSync(tempConfigDir, { recursive: true, force: true })
34+
fs.rmSync(tempProjectsDir, { recursive: true, force: true })
35+
} catch {
36+
// Ignore cleanup errors
37+
}
38+
})
39+
40+
test('returns empty array when recent-projects.json does not exist', () => {
41+
expect(loadRecentProjects()).toEqual([])
42+
})
43+
44+
test('saves and loads existing project directory', () => {
45+
const projectA = path.join(tempProjectsDir, 'project-a')
46+
fs.mkdirSync(projectA, { recursive: true })
47+
48+
saveRecentProject(projectA)
49+
50+
const loaded = loadRecentProjects()
51+
expect(loaded.length).toBe(1)
52+
expect(loaded[0].path).toBe(path.resolve(projectA))
53+
})
54+
55+
test('canonicalizes paths to prevent duplicate entries', () => {
56+
const projectA = path.join(tempProjectsDir, 'project-a')
57+
fs.mkdirSync(projectA, { recursive: true })
58+
59+
// Save with trailing slash
60+
saveRecentProject(projectA + path.sep)
61+
expect(loadRecentProjects().length).toBe(1)
62+
63+
// Save again without trailing slash
64+
saveRecentProject(projectA)
65+
expect(loadRecentProjects().length).toBe(1)
66+
expect(loadRecentProjects()[0].path).toBe(path.resolve(projectA))
67+
})
68+
69+
test('ignores non-existent project directories', () => {
70+
const nonExistent = path.join(tempProjectsDir, 'does-not-exist')
71+
saveRecentProject(nonExistent)
72+
73+
expect(loadRecentProjects()).toEqual([])
74+
})
75+
76+
test('removes project by canonical path', () => {
77+
const projectA = path.join(tempProjectsDir, 'project-a')
78+
const projectB = path.join(tempProjectsDir, 'project-b')
79+
fs.mkdirSync(projectA, { recursive: true })
80+
fs.mkdirSync(projectB, { recursive: true })
81+
82+
saveRecentProject(projectA)
83+
saveRecentProject(projectB)
84+
expect(loadRecentProjects().length).toBe(2)
85+
86+
// Remove projectA with trailing slash
87+
removeRecentProject(projectA + path.sep)
88+
const remaining = loadRecentProjects()
89+
expect(remaining.length).toBe(1)
90+
expect(remaining[0].path).toBe(path.resolve(projectB))
91+
})
92+
93+
test('clears all recent projects', () => {
94+
const projectA = path.join(tempProjectsDir, 'project-a')
95+
fs.mkdirSync(projectA, { recursive: true })
96+
97+
saveRecentProject(projectA)
98+
expect(loadRecentProjects().length).toBe(1)
99+
100+
clearRecentProjects()
101+
expect(loadRecentProjects()).toEqual([])
102+
})
103+
104+
test('filters out projects that have been deleted from disk', () => {
105+
const projectA = path.join(tempProjectsDir, 'project-a')
106+
const projectB = path.join(tempProjectsDir, 'project-b')
107+
fs.mkdirSync(projectA, { recursive: true })
108+
fs.mkdirSync(projectB, { recursive: true })
109+
110+
saveRecentProject(projectA)
111+
saveRecentProject(projectB)
112+
expect(loadRecentProjects().length).toBe(2)
113+
114+
// Delete projectA from disk
115+
fs.rmSync(projectA, { recursive: true, force: true })
116+
117+
const loaded = loadRecentProjects()
118+
expect(loaded.length).toBe(1)
119+
expect(loaded[0].path).toBe(path.resolve(projectB))
120+
})
121+
122+
test('caps recent projects at MAX_RECENT_PROJECTS with newest first', () => {
123+
const projectPaths: string[] = []
124+
for (let i = 0; i < 12; i++) {
125+
const p = path.join(tempProjectsDir, `project-${i}`)
126+
fs.mkdirSync(p, { recursive: true })
127+
projectPaths.push(p)
128+
saveRecentProject(p)
129+
}
130+
131+
const loaded = loadRecentProjects()
132+
expect(loaded.length).toBe(10)
133+
// Most recent (project-11) should be first
134+
expect(loaded[0].path).toBe(path.resolve(projectPaths[11]))
135+
})
136+
})

cli/src/index.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,9 @@ async function main(): Promise<void> {
234234
const homeDir = os.homedir()
235235
const startCwd = process.cwd()
236236
const showProjectPicker = shouldShowProjectPicker(startCwd, homeDir)
237+
if (!showProjectPicker) {
238+
saveRecentProject(projectRoot)
239+
}
237240

238241
// Requires analytics to be initialized, which is done in initializeApp
239242
trackEvent(AnalyticsEvent.APP_LAUNCHED, {

cli/src/utils/recent-projects.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import fs from 'fs'
22
import path from 'path'
33

4-
import { getConfigDir } from './auth'
4+
import { getConfigDir } from './config-dir'
55
import { logger } from './logger'
66

77
const MAX_RECENT_PROJECTS = 10
@@ -88,12 +88,13 @@ export const clearRecentProjects = (): void => {
8888
* Remove a specific project from the recent projects list
8989
*/
9090
export const removeRecentProject = (projectPath: string): void => {
91+
const resolvedPath = path.resolve(projectPath)
9192
const recentProjectsPath = getRecentProjectsPath()
9293

9394
try {
9495
const existingProjects = loadRecentProjects()
9596
const filteredProjects = existingProjects.filter(
96-
(p) => p.path !== projectPath,
97+
(p) => path.resolve(p.path) !== resolvedPath,
9798
)
9899

99100
fs.writeFileSync(
@@ -115,9 +116,11 @@ export const removeRecentProject = (projectPath: string): void => {
115116
* Validates that the path exists before saving.
116117
*/
117118
export const saveRecentProject = (projectPath: string): void => {
119+
const resolvedPath = path.resolve(projectPath)
120+
118121
// Validate path exists before saving
119-
if (!fs.existsSync(projectPath)) {
120-
logger.debug({ projectPath }, 'Skipping save for non-existent project path')
122+
if (!fs.existsSync(resolvedPath)) {
123+
logger.debug({ projectPath: resolvedPath }, 'Skipping save for non-existent project path')
121124
return
122125
}
123126

@@ -134,12 +137,12 @@ export const saveRecentProject = (projectPath: string): void => {
134137

135138
// Remove the project if it already exists (we'll add it back at the top)
136139
const filteredProjects = existingProjects.filter(
137-
(p) => p.path !== projectPath,
140+
(p) => path.resolve(p.path) !== resolvedPath,
138141
)
139142

140143
// Add the new/updated project at the beginning
141144
const updatedProjects: RecentProject[] = [
142-
{ path: projectPath, lastOpened: Date.now() },
145+
{ path: resolvedPath, lastOpened: Date.now() },
143146
...filteredProjects,
144147
].slice(0, MAX_RECENT_PROJECTS)
145148

0 commit comments

Comments
 (0)