fix: restore local main fixes stranded in worktrees

Bring back the narrow, previously validated sidebar recovery and Windows x64 sidecar compatibility fixes that were present on worktree branches but absent from the canonical local main checkout. The broader worktree branch is intentionally not merged because it would replay unrelated history and risk dropping current main work.

Constraint: The local main worktree already contains unrelated Settings and portable storage edits, so only the recovery hunks are staged.

Rejected: Merge 1506086927/main wholesale | its history includes broad rollback/replay commits that would remove current main changes.

Confidence: high

Scope-risk: narrow

Directive: Recover future worktree fixes by cherry-picking or staging narrow hunks; do not merge drifted worktree branches wholesale.

Tested: cd desktop && bun run test -- --run src/components/layout/Sidebar.test.tsx scripts/build-sidecars.test.ts

Not-tested: Full bun run verify due unrelated dirty Settings/portable-storage worktree changes.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-18 22:28:04 +08:00
parent 8a5b3467c5
commit 8ee4bf682a
6 changed files with 168 additions and 12 deletions

View File

@ -0,0 +1,19 @@
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
function readBuildScript() {
return readFileSync(path.resolve(import.meta.dirname, 'build-sidecars.ts'), 'utf8')
}
function extractWindowsX64BunTarget(source: string) {
const match = source.match(/case 'x86_64-pc-windows-msvc':[\s\S]*?return '([^']+)'/)
return match?.[1] ?? null
}
describe('build-sidecars Windows x64 target mapping', () => {
it('uses the baseline Bun runtime so older CPUs do not crash with Illegal Instruction', () => {
expect(extractWindowsX64BunTarget(readBuildScript())).toBe('bun-windows-x64-baseline')
})
})

View File

@ -72,7 +72,9 @@ function mapTargetTripleToBun(triple: string) {
case 'x86_64-apple-darwin':
return 'bun-darwin-x64'
case 'x86_64-pc-windows-msvc':
return 'bun-windows-x64'
// Prefer baseline on Windows x64 so older CPUs do not crash before the
// desktop app can even start the local sidecar process.
return 'bun-windows-x64-baseline'
case 'aarch64-pc-windows-msvc':
return 'bun-windows-arm64'
case 'x86_64-unknown-linux-gnu':

View File

@ -54,9 +54,10 @@ vi.mock('../../i18n', () => ({
'sidebar.openInFinder': 'Open in Finder',
'sidebar.openInFinderFailed': 'Could not open the project in Finder.',
'sidebar.openInFinderUnavailable': 'No file manager is available.',
'sidebar.hideProjectFromSidebar': 'Remove from Sidebar',
'sidebar.hideProjectFromSidebar': 'Hide from Sidebar',
'sidebar.restoreProjectToSidebar': 'Restore to Sidebar',
'sidebar.projectHidden': '{project} was removed from the sidebar.',
'sidebar.restoreHiddenProjects': 'Restore hidden projects ({count})',
'sidebar.projectHidden': '{project} was hidden from the sidebar. Existing sessions were not deleted.',
'sidebar.newSessionInProject': 'New session in {project}',
'sidebar.showMoreSessions': 'Expand display',
'sidebar.showFewerSessions': 'Collapse display',
@ -517,7 +518,7 @@ describe('Sidebar', () => {
expect(screen.getByRole('menuitem', { name: 'Pin Project' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'Open in Finder' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'Remove from Sidebar' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'Hide from Sidebar' })).toBeInTheDocument()
expect(screen.queryByRole('menuitem', { name: 'Create Permanent Worktree' })).not.toBeInTheDocument()
expect(screen.queryByRole('menuitem', { name: 'Rename Project' })).not.toBeInTheDocument()
expect(screen.queryByRole('menuitem', { name: 'Archive Conversations' })).not.toBeInTheDocument()
@ -564,7 +565,7 @@ describe('Sidebar', () => {
render(<Sidebar />)
fireEvent.click(screen.getByRole('button', { name: 'Project actions for beta' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Remove from Sidebar' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Hide from Sidebar' }))
await waitFor(() => {
expect(screen.queryByText('beta')).not.toBeInTheDocument()
@ -575,7 +576,7 @@ describe('Sidebar', () => {
expect(JSON.parse(window.localStorage.getItem(PROJECT_HIDDEN_STORAGE_KEY) ?? '[]')).toEqual(['/workspace/beta'])
expect(addToast).toHaveBeenCalledWith({
type: 'info',
message: 'beta was removed from the sidebar.',
message: 'beta was hidden from the sidebar. Existing sessions were not deleted.',
})
})
@ -596,6 +597,74 @@ describe('Sidebar', () => {
expect(screen.queryByTestId('project-filter')).not.toBeInTheDocument()
})
it('restores hidden projects from the project header menu', async () => {
window.localStorage.setItem(PROJECT_HIDDEN_STORAGE_KEY, JSON.stringify(['/workspace/beta']))
const now = new Date().toISOString()
useSessionStore.setState({
sessions: [
makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', now),
makeSession('beta-1', 'Beta Session', '/workspace/beta', now),
],
})
render(<Sidebar />)
expect(screen.getByText('alpha')).toBeInTheDocument()
expect(screen.queryByText('beta')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Project menu' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Restore hidden projects (1)' }))
await waitFor(() => {
expect(screen.getByText('beta')).toBeInTheDocument()
})
expect(JSON.parse(window.localStorage.getItem(PROJECT_HIDDEN_STORAGE_KEY) ?? '[]')).toEqual([])
expect(desktopUiPreferencesApiMock.updateSidebarPreferences).toHaveBeenCalledWith({
projectOrder: [],
pinnedProjects: [],
hiddenProjects: [],
projectOrganization: 'recentProject',
projectSortBy: 'updatedAt',
})
})
it('restores a hidden project when a new session is created in that project', async () => {
window.localStorage.setItem(PROJECT_HIDDEN_STORAGE_KEY, JSON.stringify(['/workspace/beta']))
createSession.mockResolvedValue('beta-new')
const now = new Date().toISOString()
useSessionStore.setState({
sessions: [
makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', now),
makeSession('beta-1', 'Beta Session', '/workspace/beta', now),
],
})
useTabStore.setState({
tabs: [{ sessionId: 'beta-1', title: 'Beta Session', type: 'session', status: 'idle' }],
activeTabId: 'beta-1',
})
render(<Sidebar />)
expect(screen.queryByText('beta')).not.toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'New Session' }))
})
await waitFor(() => {
expect(createSession).toHaveBeenCalledWith('/workspace/beta')
expect(screen.getByText('beta')).toBeInTheDocument()
})
expect(JSON.parse(window.localStorage.getItem(PROJECT_HIDDEN_STORAGE_KEY) ?? '[]')).toEqual([])
expect(desktopUiPreferencesApiMock.updateSidebarPreferences).toHaveBeenCalledWith({
projectOrder: [],
pinnedProjects: [],
hiddenProjects: [],
projectOrganization: 'recentProject',
projectSortBy: 'updatedAt',
})
})
it('uses server sidebar preferences across browser and desktop storage contexts', async () => {
desktopUiPreferencesApiMock.getPreferences.mockResolvedValueOnce({
exists: true,

View File

@ -156,6 +156,22 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
void desktopUiPreferencesApi.updateSidebarPreferences(normalized).catch(() => undefined)
}, [])
const restoreHiddenProjectForWorkDir = useCallback((workDir: string | null | undefined) => {
if (!workDir) return
setHiddenProjectKeys((current) => {
const next = new Set([...current].filter((projectKey) => !projectPathMatches(projectKey, workDir)))
if (next.size === current.size) return current
persistSidebarProjectPreferences(buildSidebarProjectPreferences(
projectOrder,
pinnedProjectKeys,
next,
projectOrganization,
projectSortBy,
))
return next
})
}, [persistSidebarProjectPreferences, pinnedProjectKeys, projectOrder, projectOrganization, projectSortBy])
useEffect(() => {
let cancelled = false
const startRevision = sidebarPreferenceRevisionRef.current
@ -247,6 +263,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
const createSessionForWorkDir = useCallback(async (workDir?: string) => {
try {
const sessionId = await useSessionStore.getState().createSession(workDir)
restoreHiddenProjectForWorkDir(workDir)
useTabStore.getState().openTab(sessionId, t('sidebar.newSession'))
useChatStore.getState().connectToSession(sessionId)
closeMobileDrawer()
@ -256,7 +273,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
message: error instanceof Error ? error.message : t('sidebar.sessionListFailed'),
})
}
}, [addToast, closeMobileDrawer, t])
}, [addToast, closeMobileDrawer, restoreHiddenProjectForWorkDir, t])
const openProjectHeaderMenu = useCallback((event: React.MouseEvent, type: SidebarHeaderMenuType) => {
event.stopPropagation()
@ -355,6 +372,23 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
})
}, [hiddenProjectKeys, persistSidebarProjectPreferences, projectOrder, projectOrganization, projectSortBy])
const restoreAllHiddenProjects = useCallback(() => {
setProjectHeaderMenu(null)
setProjectHeaderSubmenu(null)
setHiddenProjectKeys((current) => {
if (current.size === 0) return current
const next = new Set<string>()
persistSidebarProjectPreferences(buildSidebarProjectPreferences(
projectOrder,
pinnedProjectKeys,
next,
projectOrganization,
projectSortBy,
))
return next
})
}, [persistSidebarProjectPreferences, pinnedProjectKeys, projectOrder, projectOrganization, projectSortBy])
const toggleHiddenProject = useCallback((project: ProjectGroup) => {
const wasHidden = hiddenProjectKeys.has(project.key)
setProjectContextMenu(null)
@ -773,7 +807,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
{searchQuery ? t('sidebar.noMatching') : t('sidebar.noSessions')}
</div>
)}
{visibleProjectGroups.length > 0 && (
{orderedProjectGroups.length > 0 && (
<ProjectHeaderActions
title={t('sidebar.projects')}
menuLabel={t('sidebar.projectMenu')}
@ -1086,6 +1120,8 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
onSetSortBy={updateProjectSortBy}
onCreateBlank={() => void createSessionForWorkDir()}
onUseExistingFolder={() => void createSessionFromExistingFolder()}
onRestoreHiddenProjects={restoreAllHiddenProjects}
hiddenProjectCount={hiddenProjectKeys.size}
t={t}
/>
)}
@ -1102,6 +1138,8 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
onSetSortBy={updateProjectSortBy}
onCreateBlank={() => void createSessionForWorkDir()}
onUseExistingFolder={() => void createSessionFromExistingFolder()}
onRestoreHiddenProjects={restoreAllHiddenProjects}
hiddenProjectCount={hiddenProjectKeys.size}
t={t}
/>
)}
@ -1266,6 +1304,8 @@ function ProjectHeaderMenu({
onSetSortBy,
onCreateBlank,
onUseExistingFolder,
onRestoreHiddenProjects,
hiddenProjectCount,
t,
}: {
type: SidebarHeaderMenuType
@ -1278,6 +1318,8 @@ function ProjectHeaderMenu({
onSetSortBy: (sortBy: SidebarProjectSortBy) => void
onCreateBlank: () => void
onUseExistingFolder: () => void
onRestoreHiddenProjects: () => void
hiddenProjectCount: number
t: ReturnType<typeof useTranslation>
}) {
const width = type === 'sort' ? 230 : type === 'create' ? 250 : 270
@ -1344,6 +1386,14 @@ function ProjectHeaderMenu({
>
{t('sidebar.sortCondition')}
</HeaderMenuItem>
{hiddenProjectCount > 0 && (
<HeaderMenuItem
icon={<RotateCcw size={18} aria-hidden="true" />}
onClick={onRestoreHiddenProjects}
>
{t('sidebar.restoreHiddenProjects', { count: hiddenProjectCount })}
</HeaderMenuItem>
)}
</div>
)
}
@ -1596,6 +1646,20 @@ function normalizeProjectKeyList(values: unknown): string[] {
return normalized
}
function normalizeProjectPathForComparison(value: string): string {
const normalized = value.replace(/\\/g, '/').replace(/\/+$/g, '')
return normalized || value
}
function projectPathMatches(projectKey: string, workDir: string): boolean {
const normalizedProjectKey = normalizeProjectPathForComparison(projectKey)
const normalizedWorkDir = normalizeProjectPathForComparison(workDir)
return normalizedProjectKey === normalizedWorkDir
|| normalizedWorkDir.startsWith(`${normalizedProjectKey}/`)
|| normalizedProjectKey.startsWith(`${normalizedWorkDir}/`)
}
function hasSidebarProjectPreferences(preferences: SidebarProjectPreferences): boolean {
return preferences.projectOrder.length > 0
|| preferences.pinnedProjects.length > 0

View File

@ -46,9 +46,10 @@ export const en = {
'sidebar.openInFinder': 'Open in Finder',
'sidebar.openInFinderFailed': 'Could not open the project in Finder.',
'sidebar.openInFinderUnavailable': 'No file manager is available.',
'sidebar.hideProjectFromSidebar': 'Remove from Sidebar',
'sidebar.hideProjectFromSidebar': 'Hide from Sidebar',
'sidebar.restoreProjectToSidebar': 'Restore to Sidebar',
'sidebar.projectHidden': '{project} was removed from the sidebar.',
'sidebar.restoreHiddenProjects': 'Restore hidden projects ({count})',
'sidebar.projectHidden': '{project} was hidden from the sidebar. Existing sessions were not deleted.',
'sidebar.newSessionInProject': 'New session in {project}',
'sidebar.showMoreSessions': 'Expand display',
'sidebar.showFewerSessions': 'Collapse display',

View File

@ -48,9 +48,10 @@ export const zh: Record<TranslationKey, string> = {
'sidebar.openInFinder': '在“访达”中打开',
'sidebar.openInFinderFailed': '无法在访达中打开项目。',
'sidebar.openInFinderUnavailable': '没有可用的文件管理器。',
'sidebar.hideProjectFromSidebar': '从侧边栏移除',
'sidebar.hideProjectFromSidebar': '从侧边栏隐藏',
'sidebar.restoreProjectToSidebar': '恢复到侧边栏',
'sidebar.projectHidden': '已从侧边栏移除 {project}。',
'sidebar.restoreHiddenProjects': '恢复隐藏项目({count}',
'sidebar.projectHidden': '已从侧边栏隐藏 {project},已有会话不会被删除。',
'sidebar.newSessionInProject': '在 {project} 中新建会话',
'sidebar.showMoreSessions': '展开显示',
'sidebar.showFewerSessions': '折叠显示',