mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
feat(desktop): add custom project display names
This commit is contained in:
@@ -4,6 +4,7 @@ import { desktopUiPreferencesApi, getProfileAvatarUrl } from './desktopUiPrefere
|
||||
|
||||
const preferences = {
|
||||
schemaVersion: 3,
|
||||
projectDisplayNames: {},
|
||||
profile: {
|
||||
displayName: 'cc-haha',
|
||||
subtitle: 'github.com/NanmiCoder/cc-haha',
|
||||
@@ -58,6 +59,62 @@ describe('desktopUiPreferencesApi', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('updates project display names and sends null to reset one', async () => {
|
||||
setBaseUrl('http://127.0.0.1:49237')
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
ok: true,
|
||||
projectKey: '/workspace/apps/../project',
|
||||
displayName: 'Project alias',
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
ok: true,
|
||||
projectKey: '/workspace/apps/../project',
|
||||
displayName: null,
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
|
||||
await expect(desktopUiPreferencesApi.updateProjectDisplayName(
|
||||
'/workspace/apps/../project',
|
||||
'Project alias',
|
||||
)).resolves.toEqual({
|
||||
ok: true,
|
||||
projectKey: '/workspace/apps/../project',
|
||||
displayName: 'Project alias',
|
||||
})
|
||||
await expect(desktopUiPreferencesApi.updateProjectDisplayName(
|
||||
'/workspace/apps/../project',
|
||||
null,
|
||||
)).resolves.toEqual({
|
||||
ok: true,
|
||||
projectKey: '/workspace/apps/../project',
|
||||
displayName: null,
|
||||
})
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'http://127.0.0.1:49237/api/desktop-ui/preferences/project-display-name',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ projectKey: '/workspace/apps/../project', displayName: 'Project alias' }),
|
||||
}),
|
||||
)
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'http://127.0.0.1:49237/api/desktop-ui/preferences/project-display-name',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ projectKey: '/workspace/apps/../project', displayName: null }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('reads only the pet projection through the scoped preference endpoint', async () => {
|
||||
setBaseUrl('http://127.0.0.1:49237')
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
|
||||
@@ -30,6 +30,7 @@ export type DesktopUiPreferences = {
|
||||
sidebar: SidebarProjectPreferences
|
||||
profile: DesktopProfilePreferences
|
||||
pet: DesktopPetPreferences
|
||||
projectDisplayNames: Record<string, string>
|
||||
}
|
||||
|
||||
export type DesktopUiPreferencesResponse = {
|
||||
@@ -46,6 +47,12 @@ export type DesktopPetPreferencesUpdateResponse =
|
||||
| { ok: true; preferences: DesktopUiPreferences }
|
||||
| { ok: true; pet: DesktopPetPreferences }
|
||||
|
||||
export type ProjectDisplayNameUpdateResponse = {
|
||||
ok: true
|
||||
projectKey: string
|
||||
displayName: string | null
|
||||
}
|
||||
|
||||
export const desktopUiPreferencesApi = {
|
||||
getPreferences() {
|
||||
return api.get<DesktopUiPreferencesResponse>('/api/desktop-ui/preferences')
|
||||
@@ -62,6 +69,13 @@ export const desktopUiPreferencesApi = {
|
||||
)
|
||||
},
|
||||
|
||||
updateProjectDisplayName(projectKey: string, displayName: string | null) {
|
||||
return api.put<ProjectDisplayNameUpdateResponse>(
|
||||
'/api/desktop-ui/preferences/project-display-name',
|
||||
{ projectKey, displayName },
|
||||
)
|
||||
},
|
||||
|
||||
updateProfilePreferences(profile: Pick<DesktopProfilePreferences, 'displayName' | 'subtitle'>) {
|
||||
return api.put<{ ok: true; preferences: DesktopUiPreferences }>(
|
||||
'/api/desktop-ui/preferences/profile',
|
||||
|
||||
@@ -113,6 +113,10 @@ import { useWorkflowStore } from '../../stores/workflowStore'
|
||||
import { workflowsApi } from '../../api/workflows'
|
||||
import { browserHost } from '../../lib/desktopHost/browserHost'
|
||||
import { settingsApi } from '../../api/settings'
|
||||
import {
|
||||
captureProjectDisplayNameHydrationRevision,
|
||||
hydrateProjectDisplayNames,
|
||||
} from '../../stores/projectDisplayNameStore'
|
||||
|
||||
/**
|
||||
* Opens the run-location pill's menu. Directory, branch and worktree all live
|
||||
@@ -197,6 +201,9 @@ describe('ChatInput file mentions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.createRepositoryBranch.mockReset()
|
||||
act(() => {
|
||||
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
|
||||
})
|
||||
mocks.webviewDragHandlers.length = 0
|
||||
Reflect.deleteProperty(window, 'desktopHost')
|
||||
delete (window as Window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__
|
||||
@@ -285,6 +292,9 @@ describe('ChatInput file mentions', () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
act(() => {
|
||||
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
|
||||
})
|
||||
vi.unstubAllGlobals()
|
||||
if (originalOffsetWidth) {
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', originalOffsetWidth)
|
||||
@@ -891,6 +901,64 @@ describe('ChatInput file mentions', () => {
|
||||
expect(screen.queryByRole('button', { name: /^Location/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reactively uses the active session project root display name while retaining the active worktree path in context', async () => {
|
||||
const worktreePath = '/repo/.claude/worktrees/desktop-main-12345678'
|
||||
useSessionStore.setState({
|
||||
sessions: [{
|
||||
id: sessionId,
|
||||
title: 'Project',
|
||||
createdAt: '2026-05-01T00:00:00.000Z',
|
||||
modifiedAt: '2026-05-01T00:00:00.000Z',
|
||||
messageCount: 1,
|
||||
projectPath: '/repo',
|
||||
projectRoot: '/repo',
|
||||
workDir: worktreePath,
|
||||
workDirExists: true,
|
||||
}],
|
||||
activeSessionId: sessionId,
|
||||
})
|
||||
mocks.getGitInfo.mockResolvedValue({
|
||||
branch: 'main',
|
||||
repoName: 'repo',
|
||||
workDir: worktreePath,
|
||||
changedFiles: 0,
|
||||
worktree: {
|
||||
enabled: true,
|
||||
path: worktreePath,
|
||||
plannedPath: null,
|
||||
sourceWorkDir: '/repo',
|
||||
slug: 'desktop-main-12345678',
|
||||
branch: 'main',
|
||||
},
|
||||
})
|
||||
|
||||
render(<ChatInput />)
|
||||
|
||||
const chip = await screen.findByTestId('run-location-readonly')
|
||||
expect(chip).toHaveTextContent('repo')
|
||||
|
||||
act(() => {
|
||||
hydrateProjectDisplayNames(
|
||||
{ '/repo': 'Personal repo' },
|
||||
captureProjectDisplayNameHydrationRevision(),
|
||||
)
|
||||
})
|
||||
|
||||
expect(chip).toHaveTextContent('Personal repo')
|
||||
|
||||
// Once the alias takes over the label, the worktree details tooltip is the
|
||||
// only place still naming the real project root and the active worktree
|
||||
// path — the toolbar variant carries no `title` for a worktree.
|
||||
expect(chip).not.toHaveAttribute('title')
|
||||
fireEvent.mouseEnter(screen.getByTestId('worktree-details-trigger'))
|
||||
|
||||
const tooltip = await screen.findByRole('tooltip')
|
||||
// Exact-text lookups: '/repo' is a prefix of the worktree path, so a
|
||||
// substring assertion would pass even if the project root row went missing.
|
||||
expect(within(tooltip).getByText('/repo')).toBeInTheDocument()
|
||||
expect(within(tooltip).getByText(worktreePath)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// The narrow layouts never adopted the in-toolbar pill: there is no room for
|
||||
// it beside the model selector, so they keep the location on its own line
|
||||
// below the panel.
|
||||
|
||||
@@ -1434,6 +1434,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
) : (
|
||||
<ProjectContextChip
|
||||
workDir={resolvedWorkDir}
|
||||
projectRoot={activeSession?.projectRoot}
|
||||
repoName={gitInfo?.repoName || null}
|
||||
branch={gitInfo?.branch || null}
|
||||
sourceWorkDir={gitInfo?.worktree?.sourceWorkDir || null}
|
||||
@@ -1539,6 +1540,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
{messageCount > 0 ? (
|
||||
<ProjectContextChip
|
||||
workDir={resolvedWorkDir}
|
||||
projectRoot={activeSession?.projectRoot}
|
||||
repoName={gitInfo?.repoName || null}
|
||||
branch={gitInfo?.branch || null}
|
||||
sourceWorkDir={gitInfo?.worktree?.sourceWorkDir || null}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import '@testing-library/jest-dom'
|
||||
import {
|
||||
captureProjectDisplayNameHydrationRevision,
|
||||
hydrateProjectDisplayNames,
|
||||
} from '../../stores/projectDisplayNameStore'
|
||||
import { ProjectContextChip } from './ProjectContextChip'
|
||||
|
||||
afterEach(() => {
|
||||
@@ -8,6 +12,18 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('ProjectContextChip', () => {
|
||||
beforeEach(() => {
|
||||
act(() => {
|
||||
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows only the source project label and worktree marker for isolated worktrees', () => {
|
||||
vi.useFakeTimers()
|
||||
render(
|
||||
@@ -62,6 +78,53 @@ describe('ProjectContextChip', () => {
|
||||
expect(trigger).toHaveAttribute('aria-describedby', tooltip.id)
|
||||
})
|
||||
|
||||
it('prioritizes the project-root display name and retains raw worktree context in its tooltip', () => {
|
||||
vi.useFakeTimers()
|
||||
const projectRoot = '/workspace/canonical-project'
|
||||
const sourceWorkDir = '/workspace/OpenCutSkill'
|
||||
const worktreePath = '/workspace/OpenCutSkill/.claude/worktrees/desktop-main-54a09f85'
|
||||
|
||||
render(
|
||||
<ProjectContextChip
|
||||
workDir={worktreePath}
|
||||
projectRoot={projectRoot}
|
||||
repoName="OpenCutSkill"
|
||||
branch="main"
|
||||
sourceWorkDir={sourceWorkDir}
|
||||
isWorktree
|
||||
worktreeSlug="desktop-main-54a09f85"
|
||||
worktreePath={worktreePath}
|
||||
/>,
|
||||
)
|
||||
|
||||
const chip = screen.getByTestId('run-location-outside')
|
||||
expect(chip).toHaveTextContent('OpenCutSkill')
|
||||
|
||||
act(() => {
|
||||
hydrateProjectDisplayNames(
|
||||
{
|
||||
[projectRoot]: 'OpenCut Studio',
|
||||
[sourceWorkDir]: 'Source project name',
|
||||
},
|
||||
captureProjectDisplayNameHydrationRevision(),
|
||||
)
|
||||
})
|
||||
|
||||
expect(chip).toHaveTextContent('OpenCut Studio')
|
||||
expect(chip).not.toHaveTextContent('Source project name')
|
||||
|
||||
// The alias replaces the folder name on the chip, so the real directory has
|
||||
// to stay reachable somewhere. For a worktree that is the details tooltip —
|
||||
// the chip itself carries no `title` in this mode.
|
||||
expect(chip).not.toHaveAttribute('title')
|
||||
fireEvent.mouseEnter(screen.getByTestId('worktree-details-trigger'))
|
||||
act(() => { vi.advanceTimersByTime(400) })
|
||||
|
||||
const tooltip = screen.getByRole('tooltip')
|
||||
expect(tooltip).toHaveTextContent(projectRoot)
|
||||
expect(tooltip).toHaveTextContent(worktreePath)
|
||||
})
|
||||
|
||||
it('does not show worktree details for a normal checkout', () => {
|
||||
render(
|
||||
<ProjectContextChip
|
||||
|
||||
@@ -2,10 +2,12 @@ import { GitFork } from 'lucide-react'
|
||||
import { Tooltip } from '@/components/ui/Tooltip'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getFileNameFromPath } from '../../lib/composerAttachments'
|
||||
import { useProjectDisplayName } from '../../stores/projectDisplayNameStore'
|
||||
import { getWorktreeDisplayName, WorktreeDetails } from './WorktreeDetails'
|
||||
|
||||
type Props = {
|
||||
workDir?: string | null
|
||||
projectRoot?: string | null
|
||||
repoName?: string | null
|
||||
branch?: string | null
|
||||
sourceWorkDir?: string | null
|
||||
@@ -30,6 +32,7 @@ function basename(path: string | null | undefined): string {
|
||||
|
||||
export function ProjectContextChip({
|
||||
workDir,
|
||||
projectRoot,
|
||||
repoName,
|
||||
branch,
|
||||
sourceWorkDir,
|
||||
@@ -41,7 +44,9 @@ export function ProjectContextChip({
|
||||
}: Props) {
|
||||
const t = useTranslation()
|
||||
const labelRoot = isWorktree ? (sourceWorkDir || workDir) : workDir
|
||||
const label = branch ? (repoName || basename(labelRoot)) : (basename(labelRoot) || repoName || '')
|
||||
const displayNameKey = projectRoot ?? sourceWorkDir ?? workDir ?? ''
|
||||
const displayName = useProjectDisplayName(displayNameKey)
|
||||
const label = displayName || (branch ? (repoName || basename(labelRoot)) : (basename(labelRoot) || repoName || ''))
|
||||
const worktreeName = getWorktreeDisplayName(worktreeSlug, worktreePath) || 'isolated'
|
||||
const isToolbar = variant === 'toolbar'
|
||||
// The chip variant hides the branch for worktrees — the worktree name already
|
||||
@@ -50,10 +55,17 @@ export function ProjectContextChip({
|
||||
const showBranch = !!branch && (isToolbar || !isWorktree)
|
||||
const title = [
|
||||
label,
|
||||
displayName ? `project root: ${displayNameKey}` : null,
|
||||
branch ? `branch: ${branch}` : null,
|
||||
workDir ? `cwd: ${workDir}` : null,
|
||||
].filter(Boolean).join('\n')
|
||||
const worktreeDetails = <WorktreeDetails name={worktreeName} path={worktreePath} />
|
||||
const worktreeDetails = (
|
||||
<WorktreeDetails
|
||||
name={worktreeName}
|
||||
path={worktreePath}
|
||||
projectRoot={displayName ? displayNameKey : null}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!label) return null
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { useState, type ComponentProps } from 'react'
|
||||
import { act, useState, type ComponentProps } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { create } from 'zustand'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
const viewportMocks = vi.hoisted(() => ({
|
||||
@@ -104,6 +104,10 @@ vi.mock('../../i18n', () => ({
|
||||
}))
|
||||
|
||||
import { RepositoryLaunchControls } from './RepositoryLaunchControls'
|
||||
import {
|
||||
captureProjectDisplayNameHydrationRevision,
|
||||
hydrateProjectDisplayNames,
|
||||
} from '../../stores/projectDisplayNameStore'
|
||||
|
||||
const HEAD_COMMIT = 'a'.repeat(40)
|
||||
const OTHER_COMMIT = 'b'.repeat(40)
|
||||
@@ -243,6 +247,9 @@ async function openBranchView() {
|
||||
|
||||
describe('RepositoryLaunchControls', () => {
|
||||
beforeEach(() => {
|
||||
act(() => {
|
||||
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
|
||||
})
|
||||
viewportMocks.isMobile = false
|
||||
viewportMocks.isTauri = false
|
||||
apiMocks.getRepositoryContext.mockReset()
|
||||
@@ -257,6 +264,12 @@ describe('RepositoryLaunchControls', () => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
|
||||
})
|
||||
})
|
||||
|
||||
it('collapses directory, branch and worktree into a single pill', async () => {
|
||||
renderControls()
|
||||
|
||||
@@ -270,6 +283,28 @@ describe('RepositoryLaunchControls', () => {
|
||||
expect(screen.queryByRole('button', { name: /Select worktree mode:/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses the repository-root display name when repository context canonicalizes the requested path', async () => {
|
||||
const workDir = '/repo/subdirectory'
|
||||
apiMocks.getRepositoryContext.mockResolvedValue({
|
||||
...okRepositoryContext,
|
||||
workDir: '/repo',
|
||||
repoRoot: '/repo',
|
||||
})
|
||||
renderControls({ workDir })
|
||||
await screen.findByRole('button', { name: 'Location: cc-haha / main' })
|
||||
|
||||
act(() => {
|
||||
hydrateProjectDisplayNames(
|
||||
{ '/repo': 'Custom repository' },
|
||||
captureProjectDisplayNameHydrationRevision(),
|
||||
)
|
||||
})
|
||||
|
||||
const pill = await screen.findByRole('button', { name: 'Location: Custom repository / main' })
|
||||
expect(pill).toHaveAttribute('title', `${workDir}\nBranch: main`)
|
||||
expect(within(pill).getByText('main')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('truncates the branch from the start so its tail survives', async () => {
|
||||
renderControls()
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '../../api/sessions'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useUIStore } from '../../stores/uiStore'
|
||||
import { useProjectDisplayName } from '../../stores/projectDisplayNameStore'
|
||||
import { RecentProjectsPanel } from '@/components/composite/DirectoryPicker'
|
||||
import { useDismissable } from '@/hooks/useDismissable'
|
||||
import { useMobileViewport } from '../../hooks/useMobileViewport'
|
||||
@@ -145,6 +146,7 @@ export function RepositoryLaunchControls({
|
||||
const isMobileBrowser = useMobileViewport() && !isDesktopRuntime()
|
||||
const isToolbar = placement === 'toolbar' && !isMobileBrowser
|
||||
const [context, setContext] = useState<RepositoryContextResult | null>(null)
|
||||
const [contextSourceWorkDir, setContextSourceWorkDir] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
@@ -207,23 +209,28 @@ export function RepositoryLaunchControls({
|
||||
useEffect(() => {
|
||||
if (!workDir) {
|
||||
setContext(null)
|
||||
setContextSourceWorkDir(null)
|
||||
setError(null)
|
||||
setLoading(false)
|
||||
onBranchChange(null)
|
||||
return
|
||||
}
|
||||
|
||||
const requestedWorkDir = workDir
|
||||
let cancelled = false
|
||||
setContextSourceWorkDir(null)
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
sessionsApi.getRepositoryContext(workDir)
|
||||
sessionsApi.getRepositoryContext(requestedWorkDir)
|
||||
.then((result) => {
|
||||
if (cancelled) return
|
||||
setContext(result)
|
||||
setContextSourceWorkDir(requestedWorkDir)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return
|
||||
setContext(null)
|
||||
setContextSourceWorkDir(null)
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -500,14 +507,18 @@ export function RepositoryLaunchControls({
|
||||
// reading either one unguarded decides on the wrong repo.
|
||||
useEffect(() => {
|
||||
if (!revealAfterPick || loading) return
|
||||
const settled = context?.workDir === revealAfterPick
|
||||
const settled = contextSourceWorkDir === revealAfterPick
|
||||
|| (!!error && workDir === revealAfterPick)
|
||||
if (!settled) return
|
||||
setRevealAfterPick(null)
|
||||
if (context?.state !== 'ok') closeMenu()
|
||||
}, [revealAfterPick, loading, context, error, workDir, closeMenu])
|
||||
}, [revealAfterPick, loading, context, contextSourceWorkDir, error, workDir, closeMenu])
|
||||
|
||||
const repoLabel = context?.repoName || basename(context?.repoRoot) || basename(workDir)
|
||||
const projectKey = contextSourceWorkDir === workDir && context
|
||||
? (context.repoRoot || context.workDir)
|
||||
: workDir
|
||||
const displayName = useProjectDisplayName(projectKey)
|
||||
const repoLabel = displayName || context?.repoName || basename(context?.repoRoot) || basename(workDir)
|
||||
const selectedBranchName = isGitReady ? (selectedBranch?.name ?? null) : null
|
||||
// The worktree cards name the branch their changes would land on.
|
||||
const branchLabel = selectedBranch?.name ?? context?.currentBranch ?? t('repoLaunch.noBranch')
|
||||
|
||||
@@ -8,11 +8,26 @@ export function getWorktreeDisplayName(
|
||||
return slug || (path ? getFileNameFromPath(path) : null)
|
||||
}
|
||||
|
||||
export function WorktreeDetails({ name, path }: { name: string; path?: string | null }) {
|
||||
export function WorktreeDetails({ name, path, projectRoot }: {
|
||||
name: string
|
||||
path?: string | null
|
||||
/**
|
||||
* Passed only when a custom display name is standing in for the folder name.
|
||||
* The chip then shows the alias and drops its `title`, so this tooltip is the
|
||||
* one place left that still tells you which real directory it points at.
|
||||
*/
|
||||
projectRoot?: string | null
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
|
||||
return (
|
||||
<dl className="grid max-w-[280px] grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-1">
|
||||
{projectRoot ? (
|
||||
<>
|
||||
<dt className="opacity-70">{t('sidebar.projectEditor.realPath')}</dt>
|
||||
<dd className="min-w-0 break-all font-mono text-[11px]">{projectRoot}</dd>
|
||||
</>
|
||||
) : null}
|
||||
<dt className="opacity-70">{t('sidebar.worktree')}</dt>
|
||||
<dd className="min-w-0 break-all font-mono text-[11px]">{name}</dd>
|
||||
{path ? (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
@@ -18,15 +19,25 @@ import { DirectoryPicker } from './DirectoryPicker'
|
||||
import { sessionsApi } from '../../api/sessions'
|
||||
import { filesystemApi } from '../../api/filesystem'
|
||||
import { browserHost } from '../../lib/desktopHost/browserHost'
|
||||
import {
|
||||
captureProjectDisplayNameHydrationRevision,
|
||||
hydrateProjectDisplayNames,
|
||||
} from '../../stores/projectDisplayNameStore'
|
||||
|
||||
describe('DirectoryPicker', () => {
|
||||
let originalInnerWidth: number
|
||||
|
||||
beforeEach(() => {
|
||||
originalInnerWidth = window.innerWidth
|
||||
act(() => {
|
||||
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
|
||||
})
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth })
|
||||
Reflect.deleteProperty(window, 'desktopHost')
|
||||
vi.restoreAllMocks()
|
||||
@@ -44,6 +55,56 @@ describe('DirectoryPicker', () => {
|
||||
expect(screen.getByRole('button')).not.toHaveTextContent('desktop-feature-rail-12345678')
|
||||
})
|
||||
|
||||
it('reactively updates an exact selected path to its custom display name while retaining its raw path tooltip', () => {
|
||||
const projectPath = '/workspace/project'
|
||||
render(<DirectoryPicker value={projectPath} onChange={vi.fn()} />)
|
||||
|
||||
const trigger = screen.getByRole('button')
|
||||
expect(trigger).toHaveTextContent('project')
|
||||
|
||||
act(() => {
|
||||
hydrateProjectDisplayNames(
|
||||
{ [projectPath]: 'Custom project' },
|
||||
captureProjectDisplayNameHydrationRevision(),
|
||||
)
|
||||
})
|
||||
|
||||
expect(trigger).toHaveTextContent('Custom project')
|
||||
expect(trigger).toHaveAttribute('title', projectPath)
|
||||
})
|
||||
|
||||
it('uses a custom display name for an exact recent project while selecting its real path', async () => {
|
||||
const projectPath = '/workspace/project'
|
||||
vi.mocked(sessionsApi.getRecentProjects).mockResolvedValue({
|
||||
projects: [{
|
||||
projectPath,
|
||||
realPath: projectPath,
|
||||
projectName: 'project',
|
||||
repoName: 'NanmiCoder/OpenCutSkill',
|
||||
branch: 'main',
|
||||
isGit: true,
|
||||
modifiedAt: '2026-05-07T00:00:00.000Z',
|
||||
sessionCount: 1,
|
||||
}],
|
||||
})
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(<DirectoryPicker value="" onChange={onChange} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
await screen.findByText('NanmiCoder/OpenCutSkill')
|
||||
act(() => {
|
||||
hydrateProjectDisplayNames(
|
||||
{ [projectPath]: 'Custom project' },
|
||||
captureProjectDisplayNameHydrationRevision(),
|
||||
)
|
||||
})
|
||||
const label = await screen.findByText('Custom project')
|
||||
fireEvent.click(label.closest('button')!)
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(projectPath)
|
||||
})
|
||||
|
||||
it('does not duplicate the branch in the selected project chip', async () => {
|
||||
vi.mocked(sessionsApi.getRecentProjects).mockResolvedValue({
|
||||
projects: [{
|
||||
|
||||
@@ -5,6 +5,7 @@ import { sessionsApi, type RecentProject } from '../../api/sessions'
|
||||
import { filesystemApi } from '../../api/filesystem'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useMobileViewport } from '../../hooks/useMobileViewport'
|
||||
import { useProjectDisplayName } from '../../stores/projectDisplayNameStore'
|
||||
import { getDesktopHost } from '../../lib/desktopHost'
|
||||
import {
|
||||
getCachedRecentProjects,
|
||||
@@ -43,6 +44,55 @@ function projectNameFromPath(filePath: string) {
|
||||
return displayRoot.split('/').filter(Boolean).pop() || filePath
|
||||
}
|
||||
|
||||
function RecentProjectItem({
|
||||
project,
|
||||
value,
|
||||
touch,
|
||||
onSelect,
|
||||
}: {
|
||||
project: RecentProject
|
||||
value: string
|
||||
touch: boolean
|
||||
onSelect: (path: string) => void
|
||||
}) {
|
||||
const displayName = useProjectDisplayName(project.realPath)
|
||||
const isSelected = project.realPath === value
|
||||
const label = displayName || project.repoName || project.projectName
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onSelect(project.realPath)}
|
||||
className={`flex w-full items-center gap-3 px-4 text-left transition-colors hover:bg-[var(--color-surface-hover)] ${
|
||||
touch ? 'min-h-[72px] py-3.5' : 'py-3'
|
||||
} ${
|
||||
isSelected ? 'bg-[var(--color-surface-selected)]' : ''
|
||||
}`}
|
||||
>
|
||||
{project.isGit ? (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-secondary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="w-5 flex-shrink-0">
|
||||
<circle cx="18" cy="18" r="3" /><circle cx="6" cy="6" r="3" />
|
||||
<path d="M13 6h3a2 2 0 0 1 2 2v7" /><line x1="6" y1="9" x2="6" y2="21" />
|
||||
</svg>
|
||||
) : (
|
||||
<span className="material-symbols-outlined w-5 flex-shrink-0 text-center text-[20px] text-[var(--color-text-secondary)]">folder</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
{label}
|
||||
</div>
|
||||
<div className="truncate font-mono text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{project.realPath}
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<span className="material-symbols-outlined flex-shrink-0 text-[18px] text-[var(--color-brand)]" style={{ fontVariationSettings: "'FILL' 1" }}>
|
||||
check
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
type PanelProps = {
|
||||
value: string
|
||||
/**
|
||||
@@ -246,42 +296,15 @@ export function RecentProjectsPanel({
|
||||
) : projects.length === 0 ? (
|
||||
<EmptyState description={t('dirPicker.noRecent')} variant="plain" size="sm" />
|
||||
) : (
|
||||
projects.map((project) => {
|
||||
const isSelected = project.realPath === value
|
||||
return (
|
||||
<button
|
||||
key={project.projectPath}
|
||||
onClick={() => handleSelect(project.realPath)}
|
||||
className={`flex w-full items-center gap-3 px-4 text-left transition-colors hover:bg-[var(--color-surface-hover)] ${
|
||||
touch ? 'min-h-[72px] py-3.5' : 'py-3'
|
||||
} ${
|
||||
isSelected ? 'bg-[var(--color-surface-selected)]' : ''
|
||||
}`}
|
||||
>
|
||||
{project.isGit ? (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-secondary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="w-5 flex-shrink-0">
|
||||
<circle cx="18" cy="18" r="3" /><circle cx="6" cy="6" r="3" />
|
||||
<path d="M13 6h3a2 2 0 0 1 2 2v7" /><line x1="6" y1="9" x2="6" y2="21" />
|
||||
</svg>
|
||||
) : (
|
||||
<span className="material-symbols-outlined w-5 flex-shrink-0 text-center text-[20px] text-[var(--color-text-secondary)]">folder</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
{project.repoName || project.projectName}
|
||||
</div>
|
||||
<div className="truncate font-mono text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{project.realPath}
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<span className="material-symbols-outlined flex-shrink-0 text-[18px] text-[var(--color-brand)]" style={{ fontVariationSettings: "'FILL' 1" }}>
|
||||
check
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
projects.map((project) => (
|
||||
<RecentProjectItem
|
||||
key={project.projectPath}
|
||||
project={project}
|
||||
value={value}
|
||||
touch={touch}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t border-[var(--color-border)]">
|
||||
@@ -359,8 +382,10 @@ export function DirectoryPicker({ value, onChange, variant = 'chip', isGitProjec
|
||||
|
||||
// Find selected project info
|
||||
const selectedProject = projects.find((p) => p.realPath === value)
|
||||
const selectedProjectKey = selectedProject?.realPath ?? value
|
||||
const selectedDisplayName = useProjectDisplayName(selectedProjectKey)
|
||||
const isWorkbar = variant === 'workbar'
|
||||
const selectedLabel = selectedProject?.repoName || selectedProject?.projectName || projectNameFromPath(value)
|
||||
const selectedLabel = selectedDisplayName || selectedProject?.repoName || selectedProject?.projectName || projectNameFromPath(value)
|
||||
const showGitIcon = selectedProject?.isGit || isGitProject
|
||||
const triggerClassName = isWorkbar
|
||||
? 'max-w-full ' + (isMobileBrowser ? 'min-h-11 ' : '') + 'group inline-flex h-9 min-w-0 items-center gap-2 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3.5 text-[13.5px] font-medium leading-none text-[var(--color-text-primary)] transition-[background-color,color,border-color] duration-150 ease-out hover:border-[var(--color-outline)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { useEffect } from 'react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useUIStore } from '../../stores/uiStore'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import {
|
||||
hydrateProjectDisplayNames,
|
||||
resolveProjectDisplayName,
|
||||
} from '../../stores/projectDisplayNameStore'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
initializeDesktopServerUrl: vi.fn(),
|
||||
@@ -90,7 +95,27 @@ vi.mock('../../i18n', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('./Sidebar', () => ({
|
||||
Sidebar: () => <aside>sidebar loaded</aside>,
|
||||
Sidebar: ({
|
||||
desktopUiPreferencesRequest,
|
||||
onDesktopUiPreferencesConsumed,
|
||||
}: {
|
||||
desktopUiPreferencesRequest?: Promise<unknown> | null
|
||||
onDesktopUiPreferencesConsumed?: (request: Promise<unknown>) => void
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
if (!desktopUiPreferencesRequest) return
|
||||
let cancelled = false
|
||||
void desktopUiPreferencesRequest
|
||||
.finally(() => {
|
||||
if (!cancelled) onDesktopUiPreferencesConsumed?.(desktopUiPreferencesRequest)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [desktopUiPreferencesRequest, onDesktopUiPreferencesConsumed])
|
||||
return <aside>sidebar loaded</aside>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('./ContentRouter', () => ({
|
||||
@@ -132,6 +157,9 @@ import { AppShell } from './AppShell'
|
||||
describe('AppShell boot flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
act(() => {
|
||||
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
|
||||
})
|
||||
mocks.isTauriRuntime = false
|
||||
mocks.isMobile = false
|
||||
mocks.initializeDesktopServerUrl.mockResolvedValue('http://127.0.0.1:3456')
|
||||
@@ -143,6 +171,7 @@ describe('AppShell boot flow', () => {
|
||||
schemaVersion: 3,
|
||||
sidebar: {},
|
||||
profile: {},
|
||||
projectDisplayNames: {},
|
||||
pet: {
|
||||
enabled: false,
|
||||
selectedPetId: 'dada-code',
|
||||
@@ -159,6 +188,7 @@ describe('AppShell boot flow', () => {
|
||||
schemaVersion: 3,
|
||||
sidebar: {},
|
||||
profile: {},
|
||||
projectDisplayNames: {},
|
||||
pet: {
|
||||
enabled: false,
|
||||
selectedPetId: 'dada-code',
|
||||
@@ -301,6 +331,16 @@ describe('AppShell boot flow', () => {
|
||||
expect(screen.queryByText('app.serverFailed')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the app usable when desktop UI preferences are unavailable', async () => {
|
||||
mocks.getDesktopUiPreferences.mockRejectedValueOnce(new Error('preferences unavailable'))
|
||||
|
||||
render(<AppShell />)
|
||||
|
||||
expect(await screen.findByText('sidebar loaded')).toBeInTheDocument()
|
||||
expect(screen.getByText('content loaded')).toBeInTheDocument()
|
||||
expect(screen.queryByText('app.serverFailed')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reconnects the restored active session tab after boot', async () => {
|
||||
mocks.tabState.activeTabId = 'session-1'
|
||||
mocks.tabState.tabs = [
|
||||
@@ -422,6 +462,7 @@ describe('AppShell boot flow', () => {
|
||||
schemaVersion: 3,
|
||||
sidebar: {},
|
||||
profile: {},
|
||||
projectDisplayNames: {},
|
||||
pet: {
|
||||
enabled: true,
|
||||
selectedPetId: 'dada-code',
|
||||
@@ -522,6 +563,44 @@ describe('AppShell boot flow', () => {
|
||||
expect(screen.queryByText('h5 connection view')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hydrates project display names before the closed H5 drawer mounts Sidebar', async () => {
|
||||
mocks.isMobile = true
|
||||
mocks.getDesktopUiPreferences.mockResolvedValueOnce({
|
||||
exists: true,
|
||||
preferences: {
|
||||
schemaVersion: 5,
|
||||
sidebar: {},
|
||||
profile: {},
|
||||
projectDisplayNames: { '/workspace/project': 'Mobile alias' },
|
||||
pet: {
|
||||
enabled: false,
|
||||
selectedPetId: 'dada-code',
|
||||
size: 144,
|
||||
collapsed: false,
|
||||
motionEnabled: true,
|
||||
lastSessionId: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
render(<AppShell />)
|
||||
|
||||
await screen.findByText('content loaded')
|
||||
expect(screen.queryByText('sidebar loaded')).not.toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(resolveProjectDisplayName('/workspace/project')).toBe('Mobile alias')
|
||||
})
|
||||
expect(mocks.getDesktopUiPreferences).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('mobile-sidebar-toggle'))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(screen.getByText('sidebar loaded')).toBeInTheDocument()
|
||||
expect(mocks.getDesktopUiPreferences).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renders a mobile drawer toggle and backdrop in browser H5 mode', async () => {
|
||||
mocks.isMobile = true
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState, type HTMLAttributes } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type HTMLAttributes } from 'react'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { ContentRouter } from './ContentRouter'
|
||||
import { ToastContainer } from '@/components/layout/Toast'
|
||||
@@ -17,7 +17,14 @@ import {
|
||||
isH5ConnectionRequiredError,
|
||||
} from '../../lib/desktopRuntime'
|
||||
import { getDesktopHost } from '../../lib/desktopHost'
|
||||
import { desktopUiPreferencesApi } from '../../api/desktopUiPreferences'
|
||||
import {
|
||||
desktopUiPreferencesApi,
|
||||
type DesktopUiPreferencesResponse,
|
||||
} from '../../api/desktopUiPreferences'
|
||||
import {
|
||||
captureProjectDisplayNameHydrationRevision,
|
||||
hydrateProjectDisplayNames,
|
||||
} from '../../stores/projectDisplayNameStore'
|
||||
import { openDesktopNotificationTarget } from '../../lib/desktopNotificationNavigation'
|
||||
import { TabBar } from './TabBar'
|
||||
import { StartupErrorView } from './StartupErrorView'
|
||||
@@ -47,6 +54,12 @@ export function AppShell() {
|
||||
const [h5StartupError, setH5StartupError] = useState<H5ConnectionRequiredError | null>(null)
|
||||
const [bootstrapNonce, setBootstrapNonce] = useState(0)
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
|
||||
const [desktopUiPreferencesRequest, setDesktopUiPreferencesRequest] = useState<
|
||||
Promise<DesktopUiPreferencesResponse> | null
|
||||
>(null)
|
||||
const consumeDesktopUiPreferencesRequest = useCallback((request: Promise<DesktopUiPreferencesResponse>) => {
|
||||
setDesktopUiPreferencesRequest((current) => current === request ? null : current)
|
||||
}, [])
|
||||
const t = useTranslation()
|
||||
const traceLaunch = useMemo(() => getTraceLaunchRequest(), [])
|
||||
const desktopRuntime = isDesktopRuntime()
|
||||
@@ -107,24 +120,34 @@ export function AppShell() {
|
||||
setReady(false)
|
||||
setStartupError(null)
|
||||
setH5StartupError(null)
|
||||
setDesktopUiPreferencesRequest(null)
|
||||
}
|
||||
|
||||
try {
|
||||
await initializeDesktopServerUrl()
|
||||
await fetchSettings()
|
||||
if (cancelled) return
|
||||
|
||||
if (!cancelled) {
|
||||
setReady(true)
|
||||
}
|
||||
|
||||
if (desktopRuntime && !traceLaunch.windowMode) {
|
||||
void desktopUiPreferencesApi.getPreferences()
|
||||
if (!traceLaunch.windowMode) {
|
||||
const displayNameHydrationRevision = captureProjectDisplayNameHydrationRevision()
|
||||
const preferencesRequest = desktopUiPreferencesApi.getPreferences()
|
||||
setDesktopUiPreferencesRequest(preferencesRequest)
|
||||
void preferencesRequest
|
||||
.then(({ preferences }) => {
|
||||
if (preferences.pet.enabled) return getDesktopHost().pets.show()
|
||||
if (cancelled) return
|
||||
hydrateProjectDisplayNames(
|
||||
preferences.projectDisplayNames ?? {},
|
||||
displayNameHydrationRevision,
|
||||
)
|
||||
if (desktopRuntime && preferences.pet.enabled) {
|
||||
return getDesktopHost().pets.show()
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
setReady(true)
|
||||
|
||||
void (async () => {
|
||||
if (traceLaunch.windowMode) return
|
||||
|
||||
@@ -308,7 +331,12 @@ export function AppShell() {
|
||||
{...sidebarHiddenProps}
|
||||
>
|
||||
{!isMobileShell || effectiveSidebarOpen ? (
|
||||
<Sidebar isMobile={isMobileShell} onRequestClose={() => setEffectiveSidebarOpen(false)} />
|
||||
<Sidebar
|
||||
isMobile={isMobileShell}
|
||||
onRequestClose={() => setEffectiveSidebarOpen(false)}
|
||||
desktopUiPreferencesRequest={desktopUiPreferencesRequest}
|
||||
onDesktopUiPreferencesConsumed={consumeDesktopUiPreferencesRequest}
|
||||
/>
|
||||
) : null}
|
||||
{!isMobileShell ? (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useState } from 'react'
|
||||
|
||||
vi.mock('../../i18n', () => ({
|
||||
useTranslation: () => (key: string, params?: Record<string, string | number>) => {
|
||||
const translations: Record<string, string> = {
|
||||
'common.cancel': 'Cancel',
|
||||
'common.save': 'Save',
|
||||
'sidebar.projectEditor.createTitle': 'Create project',
|
||||
'sidebar.projectEditor.editTitle': 'Edit project',
|
||||
'sidebar.projectEditor.name': 'Project name',
|
||||
'sidebar.projectEditor.nameHint': 'Up to {count} characters',
|
||||
'sidebar.projectEditor.nameRequired': 'A project name is required.',
|
||||
'sidebar.projectEditor.nameTooLong': 'Project names can be at most 80 characters.',
|
||||
'sidebar.projectEditor.sourceFolder': 'Source folder',
|
||||
'sidebar.projectEditor.sourceFolderRequired': 'Choose a source folder.',
|
||||
'sidebar.projectEditor.realPath': 'Real path',
|
||||
'sidebar.projectEditor.realPathHint': 'This project path is fixed and cannot be changed here.',
|
||||
'sidebar.projectEditor.restoreFolderName': 'Restore folder name',
|
||||
'sidebar.projectEditor.removeFromSidebar': 'Remove from sidebar',
|
||||
'sidebar.projectEditor.removeFromSidebarHint': 'This only removes the project from the sidebar. Its sessions and files remain unchanged.',
|
||||
'sidebar.projectEditor.create': 'Create project',
|
||||
'sidebar.projectEditor.actionFailed': 'Could not save the project changes.',
|
||||
}
|
||||
let value = translations[key] ?? key
|
||||
for (const [name, replacement] of Object.entries(params ?? {})) {
|
||||
value = value.replace(new RegExp(`\\{${name}\\}`, 'g'), String(replacement))
|
||||
}
|
||||
return value
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/components/composite/DirectoryPicker', () => ({
|
||||
DirectoryPicker: ({ value, onChange }: { value: string; onChange: (path: string) => void }) => (
|
||||
<button type="button" aria-label="Choose source folder" onClick={() => onChange('/workspace/selected ')}>
|
||||
{value || 'Choose source folder'}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
import { ProjectEditorModal } from './ProjectEditorModal'
|
||||
|
||||
function CreateHarness({
|
||||
onSubmit,
|
||||
onSourceFolderChange,
|
||||
}: {
|
||||
onSubmit: ReturnType<typeof vi.fn>
|
||||
onSourceFolderChange: ReturnType<typeof vi.fn>
|
||||
}) {
|
||||
const [sourceFolder, setSourceFolder] = useState('')
|
||||
|
||||
return (
|
||||
<ProjectEditorModal
|
||||
open
|
||||
mode="create"
|
||||
sourceFolder={sourceFolder}
|
||||
logicalRoot="/workspace/logical-root "
|
||||
suggestedName="Suggested project"
|
||||
onSourceFolderChange={(path) => {
|
||||
onSourceFolderChange(path)
|
||||
setSourceFolder(path)
|
||||
}}
|
||||
onClose={vi.fn()}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('ProjectEditorModal', () => {
|
||||
it('validates normalized names and preserves exact source and logical paths', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
const onSourceFolderChange = vi.fn()
|
||||
render(<CreateHarness onSubmit={onSubmit} onSourceFolderChange={onSourceFolderChange} />)
|
||||
|
||||
const name = screen.getByRole('textbox', { name: /Project name/ })
|
||||
expect(name).not.toHaveAttribute('maxlength')
|
||||
|
||||
fireEvent.change(name, { target: { value: '' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create project' }))
|
||||
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('A project name is required.')).toHaveAttribute('role', 'alert')
|
||||
expect(screen.getByText('Choose a source folder.')).toHaveAttribute('role', 'alert')
|
||||
expect(document.activeElement).toBe(name)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Choose source folder' }))
|
||||
expect(onSourceFolderChange).toHaveBeenCalledWith('/workspace/selected ')
|
||||
|
||||
fireEvent.change(name, { target: { value: 'x'.repeat(81) } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create project' }))
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Project names can be at most 80 characters.')).toHaveAttribute('role', 'alert')
|
||||
|
||||
fireEvent.change(name, { target: { value: ' Client workspace ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create project' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
name: 'Client workspace',
|
||||
sourceFolder: '/workspace/selected ',
|
||||
logicalRoot: '/workspace/logical-root ',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('adopts late suggestions until a user edits the name', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
const baseProps = {
|
||||
open: true as const,
|
||||
mode: 'create' as const,
|
||||
sourceFolder: '/workspace/checkout',
|
||||
onSourceFolderChange: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
onSubmit,
|
||||
}
|
||||
const { rerender } = render(
|
||||
<ProjectEditorModal
|
||||
{...baseProps}
|
||||
suggestedName="checkout"
|
||||
/>,
|
||||
)
|
||||
|
||||
const name = screen.getByRole('textbox', { name: /Project name/ })
|
||||
expect(name).toHaveValue('checkout')
|
||||
|
||||
rerender(
|
||||
<ProjectEditorModal
|
||||
{...baseProps}
|
||||
logicalRoot="/workspace/repository"
|
||||
suggestedName="repository"
|
||||
/>,
|
||||
)
|
||||
await waitFor(() => expect(name).toHaveValue('repository'))
|
||||
|
||||
fireEvent.change(name, { target: { value: 'My custom name' } })
|
||||
rerender(
|
||||
<ProjectEditorModal
|
||||
{...baseProps}
|
||||
logicalRoot="/workspace/late-root"
|
||||
suggestedName="late-root"
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(name).toHaveValue('My custom name'))
|
||||
})
|
||||
|
||||
it('does not trim the fallback folder name', () => {
|
||||
render(
|
||||
<ProjectEditorModal
|
||||
open
|
||||
mode="create"
|
||||
sourceFolder="/workspace/folder "
|
||||
onSourceFolderChange={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('textbox', { name: /Project name/ })).toHaveValue('folder ')
|
||||
})
|
||||
|
||||
it('keeps the real path read-only and delegates edit-only actions', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
const onRestoreFolderName = vi.fn()
|
||||
const onRemoveFromSidebar = vi.fn()
|
||||
render(
|
||||
<ProjectEditorModal
|
||||
open
|
||||
mode="edit"
|
||||
initialName="Client workspace"
|
||||
logicalRoot="/workspace/client"
|
||||
suggestedName="client"
|
||||
onClose={vi.fn()}
|
||||
onSubmit={onSubmit}
|
||||
onRestoreFolderName={onRestoreFolderName}
|
||||
onRemoveFromSidebar={onRemoveFromSidebar}
|
||||
/>,
|
||||
)
|
||||
|
||||
const path = screen.getByRole('textbox', { name: 'Real path' })
|
||||
expect(path).toHaveValue('/workspace/client')
|
||||
expect(path).toHaveAttribute('readonly')
|
||||
expect(path).toHaveAttribute('aria-readonly', 'true')
|
||||
expect(screen.getByText('This only removes the project from the sidebar. Its sessions and files remain unchanged.')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Restore folder name' }))
|
||||
await waitFor(() => expect(onRestoreFolderName).toHaveBeenCalledTimes(1))
|
||||
expect(screen.getByRole('textbox', { name: /Project name/ })).toHaveValue('client')
|
||||
|
||||
const removeFromSidebar = screen.getByRole('button', { name: 'Remove from sidebar' })
|
||||
expect(removeFromSidebar).toHaveClass('border-[var(--color-error)]')
|
||||
fireEvent.click(removeFromSidebar)
|
||||
await waitFor(() => expect(onRemoveFromSidebar).toHaveBeenCalledTimes(1))
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox', { name: /Project name/ }), { target: { value: 'Renamed client' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
name: 'Renamed client',
|
||||
sourceFolder: '/workspace/client',
|
||||
logicalRoot: '/workspace/client',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('prevents duplicate submissions and dismissals while an action is pending', async () => {
|
||||
let resolveSubmit!: () => void
|
||||
const onSubmit = vi.fn(() => new Promise<void>((resolve) => {
|
||||
resolveSubmit = resolve
|
||||
}))
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<ProjectEditorModal
|
||||
open
|
||||
mode="edit"
|
||||
initialName="Client workspace"
|
||||
logicalRoot="/workspace/client"
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
)
|
||||
|
||||
const save = screen.getByRole('button', { name: 'Save' })
|
||||
fireEvent.click(save)
|
||||
fireEvent.click(save)
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
|
||||
await waitFor(() => expect(save).toHaveAttribute('aria-busy', 'true'))
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: 'Edit project' })
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
fireEvent.click(dialog.previousElementSibling!)
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Close dialog' }))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => resolveSubmit())
|
||||
await waitFor(() => expect(save).not.toHaveAttribute('aria-busy'))
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,275 @@
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
|
||||
import { DirectoryPicker } from '@/components/composite/DirectoryPicker'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
export type ProjectEditorSubmission = {
|
||||
name: string
|
||||
sourceFolder: string
|
||||
logicalRoot: string
|
||||
}
|
||||
|
||||
type ProjectEditorAction = () => void | Promise<void>
|
||||
|
||||
type ProjectEditorBaseProps = {
|
||||
open: boolean
|
||||
initialName?: string
|
||||
logicalRoot?: string
|
||||
suggestedName?: string
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
onClose: () => void
|
||||
onSubmit: (submission: ProjectEditorSubmission) => void | Promise<void>
|
||||
}
|
||||
|
||||
type CreateProjectEditorProps = ProjectEditorBaseProps & {
|
||||
mode: 'create'
|
||||
sourceFolder: string
|
||||
onSourceFolderChange: (path: string) => void
|
||||
}
|
||||
|
||||
type EditProjectEditorProps = ProjectEditorBaseProps & {
|
||||
mode: 'edit'
|
||||
logicalRoot: string
|
||||
onRestoreFolderName?: ProjectEditorAction
|
||||
onRemoveFromSidebar?: ProjectEditorAction
|
||||
}
|
||||
|
||||
export type ProjectEditorModalProps = CreateProjectEditorProps | EditProjectEditorProps
|
||||
|
||||
function folderName(path: string): string {
|
||||
const segments = path.replace(/[\\/]+$/, '').split(/[\\/]+/).filter(Boolean)
|
||||
return segments[segments.length - 1] ?? ''
|
||||
}
|
||||
|
||||
function normalizeProjectName(name: string): string {
|
||||
return name.trim().replace(/\s+/g, ' ')
|
||||
}
|
||||
|
||||
function messageFromError(error: unknown, fallback: string): string {
|
||||
return error instanceof Error && error.message ? error.message : fallback
|
||||
}
|
||||
|
||||
export function ProjectEditorModal(props: ProjectEditorModalProps) {
|
||||
const t = useTranslation()
|
||||
const {
|
||||
open,
|
||||
mode,
|
||||
initialName,
|
||||
logicalRoot,
|
||||
suggestedName,
|
||||
loading = false,
|
||||
error,
|
||||
onClose,
|
||||
onSubmit,
|
||||
} = props
|
||||
const sourceFolder = mode === 'create' ? props.sourceFolder : logicalRoot ?? ''
|
||||
const suggestedFolderName = suggestedName?.trim() || folderName(logicalRoot || sourceFolder)
|
||||
const [name, setName] = useState(() => initialName ?? suggestedFolderName)
|
||||
const [showValidation, setShowValidation] = useState(false)
|
||||
const [pending, setPending] = useState(false)
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
const nameInputId = useId()
|
||||
const sourceFolderRef = useRef<HTMLDivElement>(null)
|
||||
const nameWasEditedRef = useRef(false)
|
||||
const useSuggestedNameRef = useRef(!initialName)
|
||||
const pendingRef = useRef(false)
|
||||
const wasOpenRef = useRef(open)
|
||||
const previousModeRef = useRef(mode)
|
||||
|
||||
const busy = loading || pending
|
||||
const normalizedName = normalizeProjectName(name)
|
||||
const nameError = !normalizedName
|
||||
? t('sidebar.projectEditor.nameRequired')
|
||||
: normalizedName.length > 80
|
||||
? t('sidebar.projectEditor.nameTooLong')
|
||||
: undefined
|
||||
const sourceFolderError = mode === 'create' && !sourceFolder.trim()
|
||||
? t('sidebar.projectEditor.sourceFolderRequired')
|
||||
: undefined
|
||||
const displayedError = error || actionError
|
||||
|
||||
useEffect(() => {
|
||||
const opened = open && !wasOpenRef.current
|
||||
const modeChanged = open && previousModeRef.current !== mode
|
||||
|
||||
if (opened || modeChanged) {
|
||||
nameWasEditedRef.current = false
|
||||
useSuggestedNameRef.current = !initialName
|
||||
setName(initialName ?? suggestedFolderName)
|
||||
setShowValidation(false)
|
||||
setActionError(null)
|
||||
} else if (open && !nameWasEditedRef.current && useSuggestedNameRef.current) {
|
||||
setName(suggestedFolderName)
|
||||
}
|
||||
|
||||
wasOpenRef.current = open
|
||||
previousModeRef.current = mode
|
||||
}, [initialName, mode, open, suggestedFolderName])
|
||||
|
||||
const runAction = async (action: ProjectEditorAction, afterSuccess?: () => void) => {
|
||||
if (loading || pendingRef.current) return
|
||||
|
||||
pendingRef.current = true
|
||||
setPending(true)
|
||||
setActionError(null)
|
||||
try {
|
||||
await action()
|
||||
afterSuccess?.()
|
||||
} catch (actionFailure) {
|
||||
setActionError(messageFromError(actionFailure, t('sidebar.projectEditor.actionFailed')))
|
||||
} finally {
|
||||
pendingRef.current = false
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (!busy) onClose()
|
||||
}
|
||||
|
||||
const focusInvalidField = () => {
|
||||
if (nameError) {
|
||||
document.getElementById(nameInputId)?.focus()
|
||||
return
|
||||
}
|
||||
sourceFolderRef.current?.querySelector<HTMLButtonElement>('button')?.focus()
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (busy) return
|
||||
if (nameError || sourceFolderError) {
|
||||
setShowValidation(true)
|
||||
focusInvalidField()
|
||||
return
|
||||
}
|
||||
|
||||
const submissionLogicalRoot = logicalRoot?.trim() ? logicalRoot : sourceFolder
|
||||
void runAction(() => onSubmit({
|
||||
name: normalizedName,
|
||||
sourceFolder,
|
||||
logicalRoot: submissionLogicalRoot,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleRestoreFolderName = () => {
|
||||
if (mode !== 'edit' || !props.onRestoreFolderName) return
|
||||
void runAction(props.onRestoreFolderName, () => {
|
||||
nameWasEditedRef.current = false
|
||||
useSuggestedNameRef.current = true
|
||||
setName(suggestedFolderName)
|
||||
})
|
||||
}
|
||||
|
||||
const handleRemoveFromSidebar = () => {
|
||||
if (mode !== 'edit' || !props.onRemoveFromSidebar) return
|
||||
void runAction(props.onRemoveFromSidebar)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
title={t(mode === 'create' ? 'sidebar.projectEditor.createTitle' : 'sidebar.projectEditor.editTitle')}
|
||||
width={520}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={handleClose} disabled={busy}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} loading={busy}>
|
||||
{t(mode === 'create' ? 'sidebar.projectEditor.create' : 'common.save')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<fieldset disabled={busy} className="m-0 min-w-0 border-0 p-0">
|
||||
<div className="flex flex-col gap-5">
|
||||
{displayedError && (
|
||||
<p role="alert" className="rounded-[var(--radius-md)] border border-[var(--color-error)] bg-[var(--color-error-container)] px-3 py-2 text-sm text-[var(--color-on-error-container)]">
|
||||
{displayedError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Input
|
||||
id={nameInputId}
|
||||
label={t('sidebar.projectEditor.name')}
|
||||
required
|
||||
value={name}
|
||||
onChange={(event) => {
|
||||
nameWasEditedRef.current = true
|
||||
useSuggestedNameRef.current = false
|
||||
setName(event.target.value)
|
||||
}}
|
||||
hint={t('sidebar.projectEditor.nameHint', { count: 80 })}
|
||||
error={showValidation ? nameError : undefined}
|
||||
/>
|
||||
|
||||
{mode === 'create' ? (
|
||||
<div
|
||||
ref={sourceFolderRef}
|
||||
role="group"
|
||||
aria-labelledby="project-editor-source-folder"
|
||||
aria-describedby={showValidation && sourceFolderError ? 'project-editor-source-folder-error' : undefined}
|
||||
className="flex flex-col gap-1.5"
|
||||
>
|
||||
<span id="project-editor-source-folder" className="text-sm font-medium text-[var(--color-text-primary)]">
|
||||
{t('sidebar.projectEditor.sourceFolder')}
|
||||
<span className="ml-0.5 text-[var(--color-error)]">*</span>
|
||||
</span>
|
||||
<DirectoryPicker value={sourceFolder} onChange={props.onSourceFolderChange} variant="workbar" />
|
||||
{showValidation && sourceFolderError && (
|
||||
<p id="project-editor-source-folder-error" role="alert" className="text-xs text-[var(--color-error)]">
|
||||
{sourceFolderError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
label={t('sidebar.projectEditor.realPath')}
|
||||
value={logicalRoot}
|
||||
readOnly
|
||||
aria-readonly="true"
|
||||
hint={t('sidebar.projectEditor.realPathHint')}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode === 'edit' && (
|
||||
<div className="flex flex-col gap-3 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
|
||||
{props.onRestoreFolderName && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--color-text-primary)]">
|
||||
{t('sidebar.projectEditor.restoreFolderName')}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-[var(--color-text-tertiary)]">
|
||||
{suggestedFolderName}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={handleRestoreFolderName}>
|
||||
{t('sidebar.projectEditor.restoreFolderName')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.onRemoveFromSidebar && (
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 border-t border-[var(--color-border)] pt-3">
|
||||
<p className="max-w-[320px] text-xs leading-5 text-[var(--color-text-secondary)]">
|
||||
{t('sidebar.projectEditor.removeFromSidebarHint')}
|
||||
</p>
|
||||
<Button variant="danger-outline" size="sm" onClick={handleRemoveFromSidebar}>
|
||||
{t('sidebar.projectEditor.removeFromSidebar')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,29 @@
|
||||
import { useState } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import type {
|
||||
DesktopUiPreferencesResponse,
|
||||
SidebarProjectPreferences,
|
||||
} from '../../api/desktopUiPreferences'
|
||||
|
||||
const desktopUiPreferencesApiMock = vi.hoisted(() => ({
|
||||
getPreferences: vi.fn(),
|
||||
updateSidebarPreferences: vi.fn(),
|
||||
updateProjectDisplayName: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/desktopUiPreferences', () => ({
|
||||
desktopUiPreferencesApi: desktopUiPreferencesApiMock,
|
||||
}))
|
||||
|
||||
const repositoryContextMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../../api/sessions', () => ({
|
||||
sessionsApi: {
|
||||
getRepositoryContext: repositoryContextMock,
|
||||
},
|
||||
}))
|
||||
|
||||
const openTargetStoreMock = vi.hoisted(() => ({
|
||||
ensureTargets: vi.fn(),
|
||||
openTarget: vi.fn(),
|
||||
@@ -49,6 +62,8 @@ vi.mock('../../i18n', () => ({
|
||||
'sidebar.sortByCreatedAt': 'Created time',
|
||||
'sidebar.sortByUpdatedAt': 'Updated time',
|
||||
'sidebar.newBlankProject': 'New blank project',
|
||||
'sidebar.newBlankSession': 'New blank session',
|
||||
'sidebar.projectEditor.editTitle': 'Edit project',
|
||||
'sidebar.useExistingFolder': 'Use existing folder',
|
||||
'sidebar.chooseProjectFolderUnavailable': 'Folder selection is only available in the desktop app.',
|
||||
'sidebar.projectActions': 'Project actions for {project}',
|
||||
@@ -109,11 +124,91 @@ vi.mock('../../i18n', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('./ProjectEditorModal', () => ({
|
||||
ProjectEditorModal: (props: {
|
||||
open: boolean
|
||||
mode: 'create' | 'edit'
|
||||
sourceFolder?: string
|
||||
logicalRoot?: string
|
||||
initialName?: string
|
||||
suggestedName?: string
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
onClose: () => void
|
||||
onSourceFolderChange?: (path: string) => void
|
||||
onSubmit: (submission: { name: string; sourceFolder: string; logicalRoot: string }) => void | Promise<void>
|
||||
onRestoreFolderName?: () => void | Promise<void>
|
||||
onRemoveFromSidebar?: () => void | Promise<void>
|
||||
}) => {
|
||||
if (!props.open) return null
|
||||
const sourceFolder = props.sourceFolder ?? props.logicalRoot ?? ''
|
||||
const logicalRoot = props.logicalRoot ?? sourceFolder
|
||||
const name = props.mode === 'create' ? 'Created project' : 'Edited project'
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={`${props.mode} project editor`}
|
||||
data-testid="project-editor-modal"
|
||||
data-source-folder={sourceFolder}
|
||||
data-logical-root={logicalRoot}
|
||||
data-initial-name={props.initialName ?? ''}
|
||||
data-suggested-name={props.suggestedName ?? ''}
|
||||
data-loading={props.loading ? 'true' : 'false'}
|
||||
>
|
||||
{props.mode === 'create' && (
|
||||
<>
|
||||
<button type="button" onClick={() => props.onSourceFolderChange?.('/workspace/repository/packages/app')}>
|
||||
Choose project source
|
||||
</button>
|
||||
<button type="button" onClick={() => props.onSourceFolderChange?.('/workspace/other')}>
|
||||
Choose other project source
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void props.onSubmit({ name, sourceFolder, logicalRoot })}
|
||||
>
|
||||
Submit {props.mode} project
|
||||
</button>
|
||||
{props.suggestedName && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void props.onSubmit({
|
||||
name: props.suggestedName!.trim().replace(/\s+/g, ' '),
|
||||
sourceFolder,
|
||||
logicalRoot,
|
||||
})}
|
||||
>
|
||||
Submit folder project name
|
||||
</button>
|
||||
)}
|
||||
{props.onRestoreFolderName && (
|
||||
<button type="button" onClick={() => void props.onRestoreFolderName?.()}>
|
||||
Restore folder name
|
||||
</button>
|
||||
)}
|
||||
{props.onRemoveFromSidebar && (
|
||||
<button type="button" onClick={() => void props.onRemoveFromSidebar?.()}>
|
||||
Remove project from sidebar
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={props.onClose}>Close project editor</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useUIStore } from '../../stores/uiStore'
|
||||
import {
|
||||
captureProjectDisplayNameHydrationRevision,
|
||||
hydrateProjectDisplayNames,
|
||||
} from '../../stores/projectDisplayNameStore'
|
||||
import type { SessionListItem } from '../../types/session'
|
||||
import type { PerSessionState } from '../../stores/chatStore'
|
||||
|
||||
@@ -169,6 +264,80 @@ function makeChatSessionState(overrides: Partial<PerSessionState> = {}): PerSess
|
||||
}
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function makeDesktopUiPreferencesResponse({
|
||||
exists = true,
|
||||
sidebar = {
|
||||
projectOrder: [],
|
||||
pinnedProjects: [],
|
||||
hiddenProjects: [],
|
||||
projectOrganization: 'recentProject',
|
||||
projectSortBy: 'updatedAt',
|
||||
},
|
||||
projectDisplayNames = {},
|
||||
}: {
|
||||
exists?: boolean
|
||||
sidebar?: SidebarProjectPreferences
|
||||
projectDisplayNames?: Record<string, string>
|
||||
} = {}): DesktopUiPreferencesResponse {
|
||||
return {
|
||||
exists,
|
||||
preferences: {
|
||||
schemaVersion: 5,
|
||||
sidebar,
|
||||
projectDisplayNames,
|
||||
profile: {
|
||||
displayName: 'cc-haha',
|
||||
subtitle: 'github.com/NanmiCoder/cc-haha',
|
||||
avatarFile: null,
|
||||
avatarUpdatedAt: null,
|
||||
},
|
||||
pet: {
|
||||
enabled: false,
|
||||
selectedPetId: 'dada-code',
|
||||
size: 144,
|
||||
showTaskPanel: true,
|
||||
collapsed: false,
|
||||
motionEnabled: true,
|
||||
lastSessionId: null,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function SidebarDrawerHarness({ request }: { request: Promise<DesktopUiPreferencesResponse> }) {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [preferencesRequest, setPreferencesRequest] = useState<
|
||||
Promise<DesktopUiPreferencesResponse> | null
|
||||
>(request)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setOpen((current) => !current)}>
|
||||
{open ? 'Close drawer harness' : 'Open drawer harness'}
|
||||
</button>
|
||||
{open && (
|
||||
<Sidebar
|
||||
isMobile
|
||||
desktopUiPreferencesRequest={preferencesRequest}
|
||||
onDesktopUiPreferencesConsumed={(consumedRequest) => {
|
||||
setPreferencesRequest((current) => current === consumedRequest ? null : current)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function makeDataTransfer() {
|
||||
const data = new Map<string, string>()
|
||||
return {
|
||||
@@ -208,9 +377,18 @@ describe('Sidebar', () => {
|
||||
deleteSession.mockReset()
|
||||
deleteSessions.mockReset()
|
||||
addToast.mockReset()
|
||||
desktopUiPreferencesApiMock.getPreferences.mockReset()
|
||||
desktopUiPreferencesApiMock.updateSidebarPreferences.mockReset()
|
||||
desktopUiPreferencesApiMock.getPreferences.mockRejectedValue(new Error('server unavailable'))
|
||||
desktopUiPreferencesApiMock.updateProjectDisplayName.mockReset()
|
||||
desktopUiPreferencesApiMock.updateProjectDisplayName.mockImplementation((projectKey: string, displayName: string | null) => Promise.resolve({
|
||||
ok: true,
|
||||
projectKey,
|
||||
displayName,
|
||||
}))
|
||||
repositoryContextMock.mockReset()
|
||||
repositoryContextMock.mockImplementation(async (workDir: string) => ({ repoRoot: null, workDir }))
|
||||
act(() => {
|
||||
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
|
||||
})
|
||||
desktopUiPreferencesApiMock.updateSidebarPreferences.mockResolvedValue({
|
||||
ok: true,
|
||||
preferences: {
|
||||
@@ -260,6 +438,9 @@ describe('Sidebar', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
cleanup()
|
||||
act(() => {
|
||||
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
|
||||
})
|
||||
useTabStore.setState({ tabs: [], activeTabId: null })
|
||||
window.localStorage.removeItem(PROJECT_ORDER_STORAGE_KEY)
|
||||
window.localStorage.removeItem(PROJECT_PINNED_STORAGE_KEY)
|
||||
@@ -525,10 +706,10 @@ describe('Sidebar', () => {
|
||||
|
||||
expect(screen.getByTestId('sidebar-projects-header')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New project' }))
|
||||
expect(screen.getByRole('menuitem', { name: 'New blank project' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: 'New blank session' })).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'New blank project' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'New blank session' }))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -537,6 +718,454 @@ describe('Sidebar', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps project controls available when there are no projects', () => {
|
||||
render(<Sidebar />)
|
||||
|
||||
expect(screen.getByTestId('sidebar-projects-header')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New project' }))
|
||||
expect(screen.getByRole('menuitem', { name: 'New blank session' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: 'Use existing folder' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses hydrated display names while preserving the project path used by actions', async () => {
|
||||
act(() => {
|
||||
hydrateProjectDisplayNames(
|
||||
{ '/workspace/alpha': 'Custom alpha' },
|
||||
captureProjectDisplayNameHydrationRevision(),
|
||||
)
|
||||
})
|
||||
createSession.mockResolvedValue('custom-alpha-new')
|
||||
useSessionStore.setState({
|
||||
sessions: [makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', new Date().toISOString())],
|
||||
})
|
||||
|
||||
render(<Sidebar />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Custom alpha')).toBeInTheDocument())
|
||||
const header = screen.getByRole('button', { name: 'Collapse Custom alpha' })
|
||||
expect(header).toHaveAttribute('title', '/workspace/alpha')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New session in Custom alpha' }))
|
||||
await waitFor(() => expect(createSession).toHaveBeenCalledWith('/workspace/alpha'))
|
||||
})
|
||||
|
||||
it('creates a named project at its resolved root while launching the selected source folder', async () => {
|
||||
repositoryContextMock.mockResolvedValue({ repoRoot: '/workspace/repository' })
|
||||
createSession.mockResolvedValue('created-project-session')
|
||||
|
||||
render(<Sidebar />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New project' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Use existing folder' }))
|
||||
|
||||
const editor = screen.getByTestId('project-editor-modal')
|
||||
expect(editor).toHaveAttribute('data-source-folder', '')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Choose project source' }))
|
||||
|
||||
await waitFor(() => expect(repositoryContextMock).toHaveBeenCalledWith('/workspace/repository/packages/app'))
|
||||
await waitFor(() => {
|
||||
expect(editor).toHaveAttribute('data-source-folder', '/workspace/repository/packages/app')
|
||||
expect(editor).toHaveAttribute('data-logical-root', '/workspace/repository')
|
||||
expect(editor).toHaveAttribute('data-suggested-name', 'repository')
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Submit create project' }))
|
||||
await waitFor(() => {
|
||||
expect(desktopUiPreferencesApiMock.updateProjectDisplayName).toHaveBeenCalledWith(
|
||||
'/workspace/repository',
|
||||
'Created project',
|
||||
)
|
||||
expect(createSession).toHaveBeenCalledWith('/workspace/repository/packages/app')
|
||||
expect(connectToSession).toHaveBeenCalledWith('created-project-session')
|
||||
})
|
||||
expect(createSession.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
desktopUiPreferencesApiMock.updateProjectDisplayName.mock.invocationCallOrder[0]!,
|
||||
)
|
||||
expect(repositoryContextMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('opens the session before repository context resolves and falls back after lookup failure', async () => {
|
||||
const repositoryContext = createDeferred<{ repoRoot: string | null; workDir?: string }>()
|
||||
repositoryContextMock.mockReturnValueOnce(repositoryContext.promise)
|
||||
createSession.mockResolvedValue('created-project-session')
|
||||
|
||||
render(<Sidebar />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New project' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Use existing folder' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Choose project source' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Submit create project' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createSession).toHaveBeenCalledWith('/workspace/repository/packages/app')
|
||||
expect(connectToSession).toHaveBeenCalledWith('created-project-session')
|
||||
expect(screen.queryByTestId('project-editor-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(desktopUiPreferencesApiMock.updateProjectDisplayName).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
repositoryContext.reject(new Error('repository context unavailable'))
|
||||
await repositoryContext.promise.catch(() => undefined)
|
||||
})
|
||||
|
||||
await waitFor(() => expect(desktopUiPreferencesApiMock.updateProjectDisplayName).toHaveBeenCalledWith(
|
||||
'/workspace/repository/packages/app',
|
||||
'Created project',
|
||||
))
|
||||
})
|
||||
|
||||
it('resets instead of persisting a redundant alias when creating with the folder name', async () => {
|
||||
repositoryContextMock.mockResolvedValue({
|
||||
repoRoot: '/workspace/repository',
|
||||
workDir: '/workspace/repository/packages/app',
|
||||
})
|
||||
createSession.mockResolvedValue('created-project-session')
|
||||
|
||||
render(<Sidebar />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New project' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Use existing folder' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Choose project source' }))
|
||||
await waitFor(() => expect(screen.getByTestId('project-editor-modal')).toHaveAttribute(
|
||||
'data-suggested-name',
|
||||
'repository',
|
||||
))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Submit folder project name' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(connectToSession).toHaveBeenCalledWith('created-project-session')
|
||||
expect(screen.queryByTestId('project-editor-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(desktopUiPreferencesApiMock.updateProjectDisplayName).toHaveBeenCalledWith(
|
||||
'/workspace/repository',
|
||||
null,
|
||||
)
|
||||
})
|
||||
|
||||
it('opens the session before reporting a project display-name save failure', async () => {
|
||||
let rejectDisplayNameSave!: (error: Error) => void
|
||||
const displayNameSave = new Promise<never>((_resolve, reject) => {
|
||||
rejectDisplayNameSave = reject
|
||||
})
|
||||
repositoryContextMock.mockResolvedValue({
|
||||
repoRoot: '/workspace/repository',
|
||||
workDir: '/workspace/repository/packages/app',
|
||||
})
|
||||
createSession.mockResolvedValue('created-project-session')
|
||||
desktopUiPreferencesApiMock.updateProjectDisplayName.mockReturnValueOnce(displayNameSave)
|
||||
|
||||
render(<Sidebar />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New project' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Use existing folder' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Choose project source' }))
|
||||
await waitFor(() => expect(repositoryContextMock).toHaveBeenCalled())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Submit create project' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createSession).toHaveBeenCalledWith('/workspace/repository/packages/app')
|
||||
expect(connectToSession).toHaveBeenCalledWith('created-project-session')
|
||||
expect(screen.queryByTestId('project-editor-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(addToast).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
rejectDisplayNameSave(new Error('Display name save failed'))
|
||||
await displayNameSave.catch(() => undefined)
|
||||
})
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'Display name save failed',
|
||||
}))
|
||||
expect(deleteSession).not.toHaveBeenCalled()
|
||||
expect(deleteSessions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not persist an orphan display name when session creation fails', async () => {
|
||||
repositoryContextMock.mockResolvedValue({
|
||||
repoRoot: '/workspace/repository',
|
||||
workDir: '/workspace/repository/packages/app',
|
||||
})
|
||||
createSession.mockRejectedValueOnce(new Error('Session creation failed'))
|
||||
|
||||
render(<Sidebar />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New project' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Use existing folder' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Choose project source' }))
|
||||
await waitFor(() => expect(repositoryContextMock).toHaveBeenCalled())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Submit create project' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createSession).toHaveBeenCalledWith('/workspace/repository/packages/app')
|
||||
expect(screen.getByTestId('project-editor-modal')).toBeInTheDocument()
|
||||
})
|
||||
expect(desktopUiPreferencesApiMock.updateProjectDisplayName).not.toHaveBeenCalled()
|
||||
expect(connectToSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the resolved non-git workDir as the project display-name key', async () => {
|
||||
repositoryContextMock.mockResolvedValue({
|
||||
repoRoot: null,
|
||||
workDir: '/real/workspace/project',
|
||||
})
|
||||
createSession.mockResolvedValue('created-project-session')
|
||||
|
||||
render(<Sidebar />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New project' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Use existing folder' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Choose project source' }))
|
||||
|
||||
await waitFor(() => {
|
||||
const editor = screen.getByTestId('project-editor-modal')
|
||||
expect(editor).toHaveAttribute('data-source-folder', '/workspace/repository/packages/app')
|
||||
expect(editor).toHaveAttribute('data-logical-root', '/real/workspace/project')
|
||||
expect(editor).toHaveAttribute('data-suggested-name', 'project')
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Submit create project' }))
|
||||
await waitFor(() => {
|
||||
expect(desktopUiPreferencesApiMock.updateProjectDisplayName).toHaveBeenCalledWith(
|
||||
'/real/workspace/project',
|
||||
'Created project',
|
||||
)
|
||||
expect(createSession).toHaveBeenCalledWith('/workspace/repository/packages/app')
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the latest logical root when an earlier lookup finishes late', async () => {
|
||||
let resolveFirstLookup: (value: { repoRoot: string | null }) => void = () => undefined
|
||||
const firstLookup = new Promise<{ repoRoot: string | null }>((resolve) => {
|
||||
resolveFirstLookup = resolve
|
||||
})
|
||||
repositoryContextMock
|
||||
.mockImplementationOnce(() => firstLookup)
|
||||
.mockResolvedValueOnce({ repoRoot: '/workspace/other' })
|
||||
|
||||
render(<Sidebar />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New project' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Use existing folder' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Choose project source' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Choose other project source' }))
|
||||
|
||||
await waitFor(() => expect(repositoryContextMock).toHaveBeenNthCalledWith(2, '/workspace/other'))
|
||||
await waitFor(() => {
|
||||
const editor = screen.getByTestId('project-editor-modal')
|
||||
expect(editor).toHaveAttribute('data-source-folder', '/workspace/other')
|
||||
expect(editor).toHaveAttribute('data-logical-root', '/workspace/other')
|
||||
expect(editor).toHaveAttribute('data-suggested-name', 'other')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
resolveFirstLookup({ repoRoot: '/workspace/repository' })
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
const editor = screen.getByTestId('project-editor-modal')
|
||||
expect(editor).toHaveAttribute('data-source-folder', '/workspace/other')
|
||||
expect(editor).toHaveAttribute('data-logical-root', '/workspace/other')
|
||||
expect(editor).toHaveAttribute('data-suggested-name', 'other')
|
||||
})
|
||||
|
||||
it('edits, resets, and removes a project without changing its real path or sessions', async () => {
|
||||
act(() => {
|
||||
hydrateProjectDisplayNames(
|
||||
{ '/workspace/alpha': 'Custom alpha' },
|
||||
captureProjectDisplayNameHydrationRevision(),
|
||||
)
|
||||
})
|
||||
useSessionStore.setState({
|
||||
sessions: [makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', new Date().toISOString())],
|
||||
})
|
||||
|
||||
render(<Sidebar />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Project actions for Custom alpha' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Edit project' }))
|
||||
const editor = screen.getByTestId('project-editor-modal')
|
||||
expect(editor).toHaveAttribute('data-logical-root', '/workspace/alpha')
|
||||
expect(editor).toHaveAttribute('data-initial-name', 'Custom alpha')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Submit edit project' }))
|
||||
await waitFor(() => {
|
||||
expect(desktopUiPreferencesApiMock.updateProjectDisplayName).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/workspace/alpha',
|
||||
'Edited project',
|
||||
)
|
||||
expect(screen.getByText('Edited project')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByRole('button', { name: 'Collapse Edited project' })).toHaveAttribute('title', '/workspace/alpha')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Project actions for Edited project' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Edit project' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Restore folder name' }))
|
||||
await waitFor(() => {
|
||||
expect(desktopUiPreferencesApiMock.updateProjectDisplayName).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/workspace/alpha',
|
||||
null,
|
||||
)
|
||||
expect(screen.getByText('alpha')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close project editor' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Project actions for alpha' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Edit project' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove project from sidebar' }))
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId('sidebar-project-group-workspace-alpha')).not.toBeInTheDocument())
|
||||
expect(deleteSession).not.toHaveBeenCalled()
|
||||
expect(deleteSessions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resets the alias when an edit saves the default folder name', async () => {
|
||||
act(() => {
|
||||
hydrateProjectDisplayNames(
|
||||
{ '/workspace/alpha': 'Custom alpha' },
|
||||
captureProjectDisplayNameHydrationRevision(),
|
||||
)
|
||||
})
|
||||
useSessionStore.setState({
|
||||
sessions: [makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', new Date().toISOString())],
|
||||
})
|
||||
|
||||
render(<Sidebar />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Project actions for Custom alpha' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Edit project' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Submit folder project name' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(desktopUiPreferencesApiMock.updateProjectDisplayName).toHaveBeenCalledWith(
|
||||
'/workspace/alpha',
|
||||
null,
|
||||
)
|
||||
expect(screen.getByText('alpha')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('resets the default name before a stale alias hydration finishes', async () => {
|
||||
const hydrationRevision = captureProjectDisplayNameHydrationRevision()
|
||||
useSessionStore.setState({
|
||||
sessions: [makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', new Date().toISOString())],
|
||||
})
|
||||
|
||||
render(<Sidebar />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Project actions for alpha' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Edit project' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Submit folder project name' }))
|
||||
|
||||
await waitFor(() => expect(desktopUiPreferencesApiMock.updateProjectDisplayName).toHaveBeenCalledWith(
|
||||
'/workspace/alpha',
|
||||
null,
|
||||
))
|
||||
|
||||
act(() => {
|
||||
hydrateProjectDisplayNames(
|
||||
{ '/workspace/alpha': 'Stale custom alpha' },
|
||||
hydrationRevision,
|
||||
)
|
||||
})
|
||||
|
||||
expect(screen.getByText('alpha')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Stale custom alpha')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('recognizes a normalized whitespace basename as the default project name', async () => {
|
||||
const projectKey = '/workspace/My project '
|
||||
act(() => {
|
||||
hydrateProjectDisplayNames(
|
||||
{ [projectKey]: 'Custom project' },
|
||||
captureProjectDisplayNameHydrationRevision(),
|
||||
)
|
||||
})
|
||||
useSessionStore.setState({
|
||||
sessions: [makeSession('project-1', 'Project Session', projectKey, new Date().toISOString())],
|
||||
})
|
||||
|
||||
render(<Sidebar />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Project actions for Custom project' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Edit project' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Submit folder project name' }))
|
||||
|
||||
await waitFor(() => expect(desktopUiPreferencesApiMock.updateProjectDisplayName).toHaveBeenCalledWith(
|
||||
projectKey,
|
||||
null,
|
||||
))
|
||||
})
|
||||
|
||||
it('keeps remove-from-sidebar idempotent after delayed preferences hide the project', async () => {
|
||||
const preferencesResponse = createDeferred<DesktopUiPreferencesResponse>()
|
||||
useSessionStore.setState({
|
||||
sessions: [makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', new Date().toISOString())],
|
||||
})
|
||||
|
||||
render(<Sidebar desktopUiPreferencesRequest={preferencesResponse.promise} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Project actions for alpha' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Edit project' }))
|
||||
|
||||
await act(async () => {
|
||||
preferencesResponse.resolve(makeDesktopUiPreferencesResponse({
|
||||
sidebar: {
|
||||
projectOrder: [],
|
||||
pinnedProjects: [],
|
||||
hiddenProjects: ['/workspace/alpha'],
|
||||
projectOrganization: 'recentProject',
|
||||
projectSortBy: 'updatedAt',
|
||||
},
|
||||
}))
|
||||
await preferencesResponse.promise
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('sidebar-project-group-workspace-alpha')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('project-editor-modal')).toBeInTheDocument()
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove project from sidebar' }))
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId('project-editor-modal')).not.toBeInTheDocument())
|
||||
expect(screen.queryByTestId('sidebar-project-group-workspace-alpha')).not.toBeInTheDocument()
|
||||
expect(desktopUiPreferencesApiMock.updateSidebarPreferences).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sorts same-named projects by their stable real-path keys', async () => {
|
||||
act(() => {
|
||||
hydrateProjectDisplayNames(
|
||||
{
|
||||
'/workspace/alpha': 'Same name',
|
||||
'/workspace/beta': 'Same name',
|
||||
},
|
||||
captureProjectDisplayNameHydrationRevision(),
|
||||
)
|
||||
})
|
||||
useSessionStore.setState({
|
||||
sessions: [
|
||||
makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', '2026-05-01T00:00:00.000Z'),
|
||||
makeSession('beta-1', 'Beta Session', '/workspace/beta', '2026-05-02T00:00:00.000Z'),
|
||||
],
|
||||
})
|
||||
|
||||
render(<Sidebar />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Project menu' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Organize sidebar' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'By project' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId(/^sidebar-project-group-/).map((group) => group.getAttribute('data-testid'))).toEqual([
|
||||
'sidebar-project-group-workspace-alpha',
|
||||
'sidebar-project-group-workspace-beta',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it('persists project header sort preferences through desktop UI settings', async () => {
|
||||
useSessionStore.setState({
|
||||
sessions: [
|
||||
@@ -615,6 +1244,7 @@ describe('Sidebar', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Project actions for alpha' }))
|
||||
|
||||
expect(screen.getByRole('menuitem', { name: 'Edit project' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: 'Pin Project' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: 'Open in Finder' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: 'Hide from Sidebar' })).toBeInTheDocument()
|
||||
@@ -765,19 +1395,15 @@ describe('Sidebar', () => {
|
||||
})
|
||||
|
||||
it('uses server sidebar preferences across browser and desktop storage contexts', async () => {
|
||||
desktopUiPreferencesApiMock.getPreferences.mockResolvedValueOnce({
|
||||
exists: true,
|
||||
preferences: {
|
||||
schemaVersion: 1,
|
||||
sidebar: {
|
||||
projectOrder: ['/workspace/beta', '/workspace/alpha'],
|
||||
pinnedProjects: ['/workspace/beta'],
|
||||
hiddenProjects: ['/workspace/alpha'],
|
||||
projectOrganization: 'recentProject',
|
||||
projectSortBy: 'updatedAt',
|
||||
},
|
||||
const preferencesRequest = Promise.resolve(makeDesktopUiPreferencesResponse({
|
||||
sidebar: {
|
||||
projectOrder: ['/workspace/beta', '/workspace/alpha'],
|
||||
pinnedProjects: ['/workspace/beta'],
|
||||
hiddenProjects: ['/workspace/alpha'],
|
||||
projectOrganization: 'recentProject',
|
||||
projectSortBy: 'updatedAt',
|
||||
},
|
||||
})
|
||||
}))
|
||||
const now = new Date().toISOString()
|
||||
useSessionStore.setState({
|
||||
sessions: [
|
||||
@@ -786,11 +1412,18 @@ describe('Sidebar', () => {
|
||||
],
|
||||
})
|
||||
|
||||
render(<Sidebar />)
|
||||
const onPreferencesConsumed = vi.fn()
|
||||
render(
|
||||
<Sidebar
|
||||
desktopUiPreferencesRequest={preferencesRequest}
|
||||
onDesktopUiPreferencesConsumed={onPreferencesConsumed}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('alpha')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('beta')).toBeInTheDocument()
|
||||
expect(onPreferencesConsumed).toHaveBeenCalledWith(preferencesRequest)
|
||||
})
|
||||
expect(JSON.parse(window.localStorage.getItem(PROJECT_ORDER_STORAGE_KEY) ?? '[]')).toEqual([
|
||||
'/workspace/beta',
|
||||
@@ -800,20 +1433,44 @@ describe('Sidebar', () => {
|
||||
expect(JSON.parse(window.localStorage.getItem(PROJECT_HIDDEN_STORAGE_KEY) ?? '[]')).toEqual(['/workspace/alpha'])
|
||||
})
|
||||
|
||||
it('migrates cached local sidebar preferences when the server file is missing after update', async () => {
|
||||
desktopUiPreferencesApiMock.getPreferences.mockResolvedValueOnce({
|
||||
exists: false,
|
||||
preferences: {
|
||||
schemaVersion: 1,
|
||||
sidebar: {
|
||||
projectOrder: [],
|
||||
pinnedProjects: [],
|
||||
hiddenProjects: [],
|
||||
projectOrganization: 'recentProject',
|
||||
projectSortBy: 'updatedAt',
|
||||
},
|
||||
},
|
||||
it('invalidates stale bootstrap preferences before a mobile drawer remount', async () => {
|
||||
const preferencesResponse = createDeferred<DesktopUiPreferencesResponse>()
|
||||
useSessionStore.setState({
|
||||
sessions: [makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', new Date().toISOString())],
|
||||
})
|
||||
|
||||
render(<SidebarDrawerHarness request={preferencesResponse.promise} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Project actions for alpha' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Hide from Sidebar' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('sidebar-project-group-workspace-alpha')).not.toBeInTheDocument()
|
||||
expect(desktopUiPreferencesApiMock.updateSidebarPreferences).toHaveBeenCalledWith({
|
||||
projectOrder: [],
|
||||
pinnedProjects: [],
|
||||
hiddenProjects: ['/workspace/alpha'],
|
||||
projectOrganization: 'recentProject',
|
||||
projectSortBy: 'updatedAt',
|
||||
})
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close drawer harness' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open drawer harness' }))
|
||||
expect(screen.queryByTestId('sidebar-project-group-workspace-alpha')).not.toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
preferencesResponse.resolve(makeDesktopUiPreferencesResponse())
|
||||
await preferencesResponse.promise
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('sidebar-project-group-workspace-alpha')).not.toBeInTheDocument()
|
||||
expect(JSON.parse(window.localStorage.getItem(PROJECT_HIDDEN_STORAGE_KEY) ?? '[]')).toEqual([
|
||||
'/workspace/alpha',
|
||||
])
|
||||
})
|
||||
|
||||
it('migrates cached local sidebar preferences when the server file is missing after update', async () => {
|
||||
const preferencesRequest = Promise.resolve(makeDesktopUiPreferencesResponse({ exists: false }))
|
||||
window.localStorage.setItem(PROJECT_HIDDEN_STORAGE_KEY, JSON.stringify(['/workspace/beta']))
|
||||
useSessionStore.setState({
|
||||
sessions: [
|
||||
@@ -822,7 +1479,7 @@ describe('Sidebar', () => {
|
||||
],
|
||||
})
|
||||
|
||||
render(<Sidebar />)
|
||||
render(<Sidebar desktopUiPreferencesRequest={preferencesRequest} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(desktopUiPreferencesApiMock.updateSidebarPreferences).toHaveBeenCalledWith({
|
||||
|
||||
@@ -13,18 +13,29 @@ import { Spinner } from '@/components/ui/Spinner'
|
||||
import { useDismissable } from '@/hooks/useDismissable'
|
||||
import { GlobalSearchModal } from '../search/GlobalSearchModal'
|
||||
import { FindInPageModal } from '../search/FindInPageModal'
|
||||
import { ProjectEditorModal, type ProjectEditorSubmission } from './ProjectEditorModal'
|
||||
import { sessionsApi } from '../../api/sessions'
|
||||
import type { SessionListItem } from '../../types/session'
|
||||
import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID, MARKET_TAB_ID } from '../../stores/tabStore'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useOpenTargetStore } from '../../stores/openTargetStore'
|
||||
import { desktopUiPreferencesApi, type SidebarProjectPreferences } from '../../api/desktopUiPreferences'
|
||||
import {
|
||||
resetProjectDisplayName,
|
||||
resolveProjectDisplayName,
|
||||
setProjectDisplayName,
|
||||
useProjectDisplayNameRevision,
|
||||
} from '../../stores/projectDisplayNameStore'
|
||||
import {
|
||||
desktopUiPreferencesApi,
|
||||
type DesktopUiPreferencesResponse,
|
||||
type SidebarProjectPreferences,
|
||||
} from '../../api/desktopUiPreferences'
|
||||
import { getDesktopHost } from '../../lib/desktopHost'
|
||||
import { hasRunningBackgroundTasks } from '../../lib/backgroundTasks'
|
||||
import { getSessionWorkspaceState } from '../../lib/sessionWorkspace'
|
||||
|
||||
const desktopHost = getDesktopHost()
|
||||
const isDesktopRuntime = desktopHost.isDesktop
|
||||
const canUseNativeDialogs = desktopHost.capabilities.dialogs
|
||||
const isWindows = typeof navigator !== 'undefined' && /Win/.test(navigator.platform)
|
||||
const SESSION_LIST_AUTO_REFRESH_MS = 30_000
|
||||
const SESSION_LIST_FOCUS_REFRESH_MIN_MS = 5_000
|
||||
@@ -48,9 +59,23 @@ type ProjectGroup = {
|
||||
sessions: SessionListItem[]
|
||||
}
|
||||
|
||||
type ProjectEditorState =
|
||||
| {
|
||||
mode: 'create'
|
||||
sourceFolder: string
|
||||
logicalRoot: string
|
||||
suggestedName: string
|
||||
}
|
||||
| {
|
||||
mode: 'edit'
|
||||
logicalRoot: string
|
||||
}
|
||||
|
||||
type SidebarProps = {
|
||||
isMobile?: boolean
|
||||
onRequestClose?: () => void
|
||||
desktopUiPreferencesRequest?: Promise<DesktopUiPreferencesResponse> | null
|
||||
onDesktopUiPreferencesConsumed?: (request: Promise<DesktopUiPreferencesResponse>) => void
|
||||
}
|
||||
|
||||
type SessionScrollAnchor = {
|
||||
@@ -58,7 +83,12 @@ type SessionScrollAnchor = {
|
||||
topOffset: number
|
||||
}
|
||||
|
||||
export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
export function Sidebar({
|
||||
isMobile = false,
|
||||
onRequestClose,
|
||||
desktopUiPreferencesRequest,
|
||||
onDesktopUiPreferencesConsumed,
|
||||
}: SidebarProps) {
|
||||
const t = useTranslation()
|
||||
const sessions = useSessionStore((s) => s.sessions)
|
||||
const isLoading = useSessionStore((s) => s.isLoading)
|
||||
@@ -105,6 +135,10 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
const [projectSortBy, setProjectSortByState] = useState<SidebarProjectSortBy>(() => readStoredProjectSortBy())
|
||||
const [draggingProjectKey, setDraggingProjectKey] = useState<string | null>(null)
|
||||
const [projectDropTarget, setProjectDropTarget] = useState<{ key: string; position: 'before' | 'after' } | null>(null)
|
||||
const [projectEditor, setProjectEditor] = useState<ProjectEditorState | null>(null)
|
||||
const [projectEditorError, setProjectEditorError] = useState<string | null>(null)
|
||||
const [projectEditorLoading, setProjectEditorLoading] = useState(false)
|
||||
const projectDisplayNameRevision = useProjectDisplayNameRevision()
|
||||
const suppressProjectClickRef = useRef<string | null>(null)
|
||||
const sessionContextMenuRef = useRef<HTMLDivElement>(null)
|
||||
const projectContextMenuRef = useRef<HTMLDivElement>(null)
|
||||
@@ -112,6 +146,11 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
const projectHeaderSubmenuRef = useRef<HTMLDivElement>(null)
|
||||
const projectHeaderActionsRef = useRef<HTMLDivElement>(null)
|
||||
const sidebarPreferenceRevisionRef = useRef(0)
|
||||
const projectRootLookupRevisionRef = useRef(0)
|
||||
const projectRootLookupRef = useRef<{
|
||||
sourceFolder: string
|
||||
request: ReturnType<typeof sessionsApi.getRepositoryContext>
|
||||
} | null>(null)
|
||||
const sessionScrollAreaRef = useRef<HTMLDivElement>(null)
|
||||
const pendingSessionScrollAnchorRef = useRef<SessionScrollAnchor | null>(null)
|
||||
const refreshSessionsNow = useSessionListAutoRefresh(fetchSessions)
|
||||
@@ -177,7 +216,10 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
// Title filtering moved into the global search modal (Cmd+K); the list shows all sessions.
|
||||
const filteredSessions = sessions
|
||||
|
||||
const projectGroups = useMemo(() => groupByProject(filteredSessions, projectSortBy), [filteredSessions, projectSortBy])
|
||||
const projectGroups = useMemo(
|
||||
() => groupByProject(filteredSessions, projectSortBy, resolveProjectDisplayName),
|
||||
[filteredSessions, projectDisplayNameRevision, projectSortBy],
|
||||
)
|
||||
const orderedProjectGroups = useMemo(
|
||||
() => applyProjectOrder(projectGroups, projectOrder, pinnedProjectKeys, projectOrganization, projectSortBy),
|
||||
[projectGroups, projectOrder, pinnedProjectKeys, projectOrganization, projectSortBy],
|
||||
@@ -237,8 +279,12 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
const normalized = normalizeSidebarProjectPreferences(preferences)
|
||||
sidebarPreferenceRevisionRef.current += 1
|
||||
writeCachedSidebarProjectPreferences(normalized)
|
||||
if (desktopUiPreferencesRequest) {
|
||||
const request = desktopUiPreferencesRequest
|
||||
queueMicrotask(() => onDesktopUiPreferencesConsumed?.(request))
|
||||
}
|
||||
void desktopUiPreferencesApi.updateSidebarPreferences(normalized).catch(() => undefined)
|
||||
}, [])
|
||||
}, [desktopUiPreferencesRequest, onDesktopUiPreferencesConsumed])
|
||||
|
||||
const restoreHiddenProjectForWorkDir = useCallback((workDir: string | null | undefined) => {
|
||||
if (!workDir) return
|
||||
@@ -257,12 +303,16 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
}, [persistSidebarProjectPreferences, pinnedProjectKeys, projectOrder, projectOrganization, projectSortBy])
|
||||
|
||||
useEffect(() => {
|
||||
if (!desktopUiPreferencesRequest) return
|
||||
|
||||
let cancelled = false
|
||||
const request = desktopUiPreferencesRequest
|
||||
const startRevision = sidebarPreferenceRevisionRef.current
|
||||
|
||||
void desktopUiPreferencesApi.getPreferences()
|
||||
void request
|
||||
.then((response) => {
|
||||
if (cancelled || startRevision !== sidebarPreferenceRevisionRef.current) return
|
||||
if (cancelled) return
|
||||
if (startRevision !== sidebarPreferenceRevisionRef.current) return
|
||||
|
||||
const localPreferences = readCachedSidebarProjectPreferences()
|
||||
const serverPreferences = normalizeSidebarProjectPreferences(response.preferences.sidebar)
|
||||
@@ -278,11 +328,14 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
.catch(() => {
|
||||
// The sidebar remains usable with the local cache if the server is still booting.
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) onDesktopUiPreferencesConsumed?.(request)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [applySidebarProjectPreferences])
|
||||
}, [applySidebarProjectPreferences, desktopUiPreferencesRequest, onDesktopUiPreferencesConsumed])
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, id: string) => {
|
||||
e.preventDefault()
|
||||
@@ -414,32 +467,134 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
))
|
||||
}, [hiddenProjectKeys, persistSidebarProjectPreferences, pinnedProjectKeys, projectOrganization])
|
||||
|
||||
const createSessionFromExistingFolder = useCallback(async () => {
|
||||
const closeProjectEditor = useCallback(() => {
|
||||
projectRootLookupRevisionRef.current += 1
|
||||
projectRootLookupRef.current = null
|
||||
setProjectEditor(null)
|
||||
setProjectEditorError(null)
|
||||
}, [])
|
||||
|
||||
const openProjectCreator = useCallback(() => {
|
||||
projectRootLookupRevisionRef.current += 1
|
||||
projectRootLookupRef.current = null
|
||||
setProjectHeaderMenu(null)
|
||||
setProjectHeaderSubmenu(null)
|
||||
if (!canUseNativeDialogs) {
|
||||
addToast({
|
||||
type: 'error',
|
||||
message: t('sidebar.chooseProjectFolderUnavailable'),
|
||||
})
|
||||
setProjectEditorError(null)
|
||||
setProjectEditor({
|
||||
mode: 'create',
|
||||
sourceFolder: '',
|
||||
logicalRoot: '',
|
||||
suggestedName: '',
|
||||
})
|
||||
}, [])
|
||||
|
||||
const updateProjectCreatorSourceFolder = useCallback((sourceFolder: string) => {
|
||||
const lookupRevision = ++projectRootLookupRevisionRef.current
|
||||
const fallbackSuggestedName = sourceFolder ? projectTitle(sourceFolder) : ''
|
||||
setProjectEditor((current) => current?.mode === 'create'
|
||||
? {
|
||||
...current,
|
||||
sourceFolder,
|
||||
logicalRoot: sourceFolder,
|
||||
suggestedName: fallbackSuggestedName,
|
||||
}
|
||||
: current)
|
||||
|
||||
if (!sourceFolder.trim()) {
|
||||
projectRootLookupRef.current = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
const selected = await getDesktopHost().dialogs.open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: t('sidebar.useExistingFolder'),
|
||||
|
||||
const request = sessionsApi.getRepositoryContext(sourceFolder)
|
||||
projectRootLookupRef.current = { sourceFolder, request }
|
||||
void request
|
||||
.then((context) => {
|
||||
if (projectRootLookupRevisionRef.current !== lookupRevision) return
|
||||
const logicalRoot = context.repoRoot || context.workDir || sourceFolder
|
||||
setProjectEditor((current) => current?.mode === 'create' && current.sourceFolder === sourceFolder
|
||||
? {
|
||||
...current,
|
||||
logicalRoot,
|
||||
suggestedName: projectTitle(logicalRoot),
|
||||
}
|
||||
: current)
|
||||
})
|
||||
if (typeof selected === 'string' && selected.trim()) {
|
||||
await createSessionForWorkDir(selected)
|
||||
.catch(() => {
|
||||
if (projectRootLookupRef.current?.request === request) {
|
||||
projectRootLookupRef.current = null
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const submitProjectCreation = useCallback(async ({ name, sourceFolder, logicalRoot }: ProjectEditorSubmission) => {
|
||||
setProjectEditorLoading(true)
|
||||
setProjectEditorError(null)
|
||||
try {
|
||||
const cachedLookup = projectRootLookupRef.current
|
||||
const contextRequest = (cachedLookup?.sourceFolder === sourceFolder
|
||||
? cachedLookup.request
|
||||
: sessionsApi.getRepositoryContext(sourceFolder))
|
||||
.catch(() => null)
|
||||
const sessionId = await useSessionStore.getState().createSession(sourceFolder)
|
||||
restoreHiddenProjectForWorkDir(sourceFolder)
|
||||
|
||||
useTabStore.getState().openTab(sessionId, t('sidebar.newSession'))
|
||||
useChatStore.getState().connectToSession(sessionId)
|
||||
closeMobileDrawer()
|
||||
closeProjectEditor()
|
||||
|
||||
const context = await contextRequest
|
||||
const resolvedLogicalRoot = context?.repoRoot || context?.workDir || logicalRoot || sourceFolder
|
||||
try {
|
||||
await saveProjectDisplayName(resolvedLogicalRoot, name)
|
||||
} catch (displayNameError) {
|
||||
addToast({
|
||||
type: 'error',
|
||||
message: displayNameError instanceof Error
|
||||
? displayNameError.message
|
||||
: t('sidebar.projectEditor.actionFailed'),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
addToast({
|
||||
type: 'error',
|
||||
message: error instanceof Error ? error.message : t('sidebar.sessionListFailed'),
|
||||
})
|
||||
setProjectEditorError(error instanceof Error ? error.message : t('sidebar.sessionListFailed'))
|
||||
} finally {
|
||||
setProjectEditorLoading(false)
|
||||
}
|
||||
}, [addToast, createSessionForWorkDir, t])
|
||||
}, [addToast, closeMobileDrawer, closeProjectEditor, restoreHiddenProjectForWorkDir, t])
|
||||
|
||||
const openProjectEditor = useCallback((project: ProjectGroup) => {
|
||||
if (project.key === 'unknown' || !project.workDir) return
|
||||
projectRootLookupRevisionRef.current += 1
|
||||
setProjectContextMenu(null)
|
||||
setProjectEditorError(null)
|
||||
setProjectEditor({ mode: 'edit', logicalRoot: project.key })
|
||||
}, [])
|
||||
|
||||
const submitProjectEdit = useCallback(async ({ name, logicalRoot }: ProjectEditorSubmission) => {
|
||||
setProjectEditorLoading(true)
|
||||
setProjectEditorError(null)
|
||||
try {
|
||||
await saveProjectDisplayName(logicalRoot, name)
|
||||
closeProjectEditor()
|
||||
} catch (error) {
|
||||
setProjectEditorError(error instanceof Error ? error.message : t('sidebar.sessionListFailed'))
|
||||
} finally {
|
||||
setProjectEditorLoading(false)
|
||||
}
|
||||
}, [closeProjectEditor, t])
|
||||
|
||||
const restoreProjectFolderName = useCallback(async (logicalRoot: string) => {
|
||||
setProjectEditorLoading(true)
|
||||
setProjectEditorError(null)
|
||||
try {
|
||||
await resetProjectDisplayName(logicalRoot)
|
||||
} catch (error) {
|
||||
setProjectEditorError(error instanceof Error ? error.message : t('sidebar.sessionListFailed'))
|
||||
throw error
|
||||
} finally {
|
||||
setProjectEditorLoading(false)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
const togglePinnedProject = useCallback((projectKey: string) => {
|
||||
setProjectContextMenu(null)
|
||||
@@ -493,6 +648,23 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
}
|
||||
}, [addToast, hiddenProjectKeys, persistSidebarProjectPreferences, pinnedProjectKeys, projectOrder, projectOrganization, projectSortBy, t])
|
||||
|
||||
const hideProjectFromSidebar = useCallback((project: ProjectGroup) => {
|
||||
setProjectContextMenu(null)
|
||||
if (hiddenProjectKeys.has(project.key)) return
|
||||
|
||||
setHiddenProjectKeys((current) => {
|
||||
if (current.has(project.key)) return current
|
||||
const next = new Set(current)
|
||||
next.add(project.key)
|
||||
persistSidebarProjectPreferences(buildSidebarProjectPreferences(projectOrder, pinnedProjectKeys, next, projectOrganization, projectSortBy))
|
||||
return next
|
||||
})
|
||||
addToast({
|
||||
type: 'info',
|
||||
message: t('sidebar.projectHidden', { project: project.title }),
|
||||
})
|
||||
}, [addToast, hiddenProjectKeys, persistSidebarProjectPreferences, pinnedProjectKeys, projectOrder, projectOrganization, projectSortBy, t])
|
||||
|
||||
const openProjectInFinder = useCallback(async (project: ProjectGroup) => {
|
||||
setProjectContextMenu(null)
|
||||
try {
|
||||
@@ -912,7 +1084,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
<EmptyState variant="inline" title={t('sidebar.noSessions')} />
|
||||
</div>
|
||||
)}
|
||||
{orderedProjectGroups.length > 0 && (
|
||||
{!showInitialLoading && (
|
||||
<ProjectHeaderActions
|
||||
title={t('sidebar.projects')}
|
||||
menuLabel={t('sidebar.projectMenu')}
|
||||
@@ -1225,6 +1397,14 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
style={positionProjectMenu(projectContextMenu.x, projectContextMenu.y)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{project.key !== 'unknown' && project.workDir && (
|
||||
<ProjectMenuItem
|
||||
icon={<SquarePen size={18} aria-hidden="true" />}
|
||||
onClick={() => openProjectEditor(project)}
|
||||
>
|
||||
{t('sidebar.projectEditor.editTitle')}
|
||||
</ProjectMenuItem>
|
||||
)}
|
||||
<ProjectMenuItem
|
||||
icon={pinned ? <PinOff size={18} aria-hidden="true" /> : <Pin size={18} aria-hidden="true" />}
|
||||
onClick={() => togglePinnedProject(project.key)}
|
||||
@@ -1260,7 +1440,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
onSetOrganization={updateProjectOrganization}
|
||||
onSetSortBy={updateProjectSortBy}
|
||||
onCreateBlank={() => void createSessionForWorkDir()}
|
||||
onUseExistingFolder={() => void createSessionFromExistingFolder()}
|
||||
onUseExistingFolder={openProjectCreator}
|
||||
onRestoreHiddenProjects={restoreAllHiddenProjects}
|
||||
hiddenProjectCount={hiddenProjectKeys.size}
|
||||
t={t}
|
||||
@@ -1279,13 +1459,54 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
onSetOrganization={updateProjectOrganization}
|
||||
onSetSortBy={updateProjectSortBy}
|
||||
onCreateBlank={() => void createSessionForWorkDir()}
|
||||
onUseExistingFolder={() => void createSessionFromExistingFolder()}
|
||||
onUseExistingFolder={openProjectCreator}
|
||||
onRestoreHiddenProjects={restoreAllHiddenProjects}
|
||||
hiddenProjectCount={hiddenProjectKeys.size}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
{projectEditor?.mode === 'create' && (
|
||||
<ProjectEditorModal
|
||||
open
|
||||
mode="create"
|
||||
sourceFolder={projectEditor.sourceFolder}
|
||||
logicalRoot={projectEditor.logicalRoot}
|
||||
suggestedName={projectEditor.suggestedName}
|
||||
loading={projectEditorLoading}
|
||||
error={projectEditorError}
|
||||
onClose={closeProjectEditor}
|
||||
onSourceFolderChange={updateProjectCreatorSourceFolder}
|
||||
onSubmit={submitProjectCreation}
|
||||
/>
|
||||
)}
|
||||
{projectEditor?.mode === 'edit' && (() => {
|
||||
const customDisplayName = resolveProjectDisplayName(projectEditor.logicalRoot)
|
||||
const project = orderedProjectGroups.find((group) => group.key === projectEditor.logicalRoot)
|
||||
return (
|
||||
<ProjectEditorModal
|
||||
open
|
||||
mode="edit"
|
||||
logicalRoot={projectEditor.logicalRoot}
|
||||
initialName={customDisplayName ?? undefined}
|
||||
suggestedName={projectTitle(projectEditor.logicalRoot)}
|
||||
loading={projectEditorLoading}
|
||||
error={projectEditorError}
|
||||
onClose={closeProjectEditor}
|
||||
onSubmit={submitProjectEdit}
|
||||
onRestoreFolderName={customDisplayName
|
||||
? () => restoreProjectFolderName(projectEditor.logicalRoot)
|
||||
: undefined}
|
||||
onRemoveFromSidebar={project
|
||||
? () => {
|
||||
hideProjectFromSidebar(project)
|
||||
closeProjectEditor()
|
||||
}
|
||||
: undefined}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingDeleteSessionId !== null}
|
||||
onClose={() => setPendingDeleteSessionId(null)}
|
||||
@@ -1517,7 +1738,7 @@ const ProjectHeaderMenu = forwardRef<HTMLDivElement, {
|
||||
return (
|
||||
<div ref={ref} role="menu" className={className} style={style} onClick={(event) => event.stopPropagation()}>
|
||||
<HeaderMenuItem icon={<SquarePen size={18} aria-hidden="true" />} onClick={onCreateBlank}>
|
||||
{t('sidebar.newBlankProject')}
|
||||
{t('sidebar.newBlankSession')}
|
||||
</HeaderMenuItem>
|
||||
<HeaderMenuItem icon={<FolderOpen size={18} aria-hidden="true" />} onClick={onUseExistingFolder}>
|
||||
{t('sidebar.useExistingFolder')}
|
||||
@@ -1620,7 +1841,11 @@ function HeaderMenuItem({
|
||||
)
|
||||
}
|
||||
|
||||
function groupByProject(sessions: SessionListItem[], sortBy: SidebarProjectSortBy): ProjectGroup[] {
|
||||
function groupByProject(
|
||||
sessions: SessionListItem[],
|
||||
sortBy: SidebarProjectSortBy,
|
||||
displayNameForProject: (projectKey: string) => string | null,
|
||||
): ProjectGroup[] {
|
||||
const groupsByKey = new Map<string, SessionListItem[]>()
|
||||
for (const session of sessions) {
|
||||
const key = getSessionProjectKey(session)
|
||||
@@ -1635,7 +1860,7 @@ function groupByProject(sessions: SessionListItem[], sortBy: SidebarProjectSortB
|
||||
const projectRoot = newest?.projectRoot || newest?.workDir || key
|
||||
return {
|
||||
key,
|
||||
title: projectTitle(projectRoot),
|
||||
title: displayNameForProject(key) || projectTitle(projectRoot),
|
||||
subtitle: projectSubtitle(projectRoot, key),
|
||||
workDir: projectRoot || newest?.workDir || undefined,
|
||||
sessions: sortedSessions,
|
||||
@@ -1657,7 +1882,10 @@ function applyProjectOrder(
|
||||
const aPinned = pinnedProjectKeys.has(a.key)
|
||||
const bPinned = pinnedProjectKeys.has(b.key)
|
||||
if (aPinned !== bPinned) return aPinned ? -1 : 1
|
||||
if (organization === 'project') return a.title.localeCompare(b.title)
|
||||
if (organization === 'project') {
|
||||
const titleOrder = a.title.localeCompare(b.title)
|
||||
return titleOrder || a.key.localeCompare(b.key)
|
||||
}
|
||||
const aIndex = orderIndex.get(a.key)
|
||||
const bIndex = orderIndex.get(b.key)
|
||||
if (aIndex !== undefined && bIndex !== undefined) return aIndex - bIndex
|
||||
@@ -1908,6 +2136,16 @@ function projectTitle(pathLike: string | null | undefined): string {
|
||||
return normalized || 'Unknown project'
|
||||
}
|
||||
|
||||
async function saveProjectDisplayName(projectKey: string, displayName: string): Promise<void> {
|
||||
const defaultDisplayName = projectTitle(projectKey).trim().replace(/\s+/g, ' ')
|
||||
if (displayName === defaultDisplayName) {
|
||||
await resetProjectDisplayName(projectKey)
|
||||
return
|
||||
}
|
||||
if (resolveProjectDisplayName(projectKey) === displayName) return
|
||||
await setProjectDisplayName(projectKey, displayName)
|
||||
}
|
||||
|
||||
function projectSubtitle(projectRoot: string | null | undefined, fallbackKey: string): string | null {
|
||||
if (!projectRoot) return fallbackKey === 'unknown' ? null : fallbackKey
|
||||
return compactProjectPath(projectRoot)
|
||||
|
||||
@@ -79,7 +79,8 @@ function preferencesResponse(pet: DesktopPetPreferences) {
|
||||
return {
|
||||
exists: true,
|
||||
preferences: {
|
||||
schemaVersion: 4,
|
||||
schemaVersion: 5,
|
||||
projectDisplayNames: {},
|
||||
sidebar: {
|
||||
projectOrder: [],
|
||||
pinnedProjects: [],
|
||||
|
||||
@@ -80,6 +80,22 @@ export const en = {
|
||||
'sidebar.sortByCreatedAt': 'Created time',
|
||||
'sidebar.sortByUpdatedAt': 'Updated time',
|
||||
'sidebar.newBlankProject': 'New blank project',
|
||||
'sidebar.newBlankSession': 'New blank session',
|
||||
'sidebar.projectEditor.createTitle': 'Create project',
|
||||
'sidebar.projectEditor.editTitle': 'Edit project',
|
||||
'sidebar.projectEditor.name': 'Project name',
|
||||
'sidebar.projectEditor.nameHint': 'Up to {count} characters',
|
||||
'sidebar.projectEditor.nameRequired': 'A project name is required.',
|
||||
'sidebar.projectEditor.nameTooLong': 'Project names can be at most 80 characters.',
|
||||
'sidebar.projectEditor.sourceFolder': 'Source folder',
|
||||
'sidebar.projectEditor.sourceFolderRequired': 'Choose a source folder.',
|
||||
'sidebar.projectEditor.realPath': 'Real path',
|
||||
'sidebar.projectEditor.realPathHint': 'This project path is fixed and cannot be changed here.',
|
||||
'sidebar.projectEditor.restoreFolderName': 'Restore folder name',
|
||||
'sidebar.projectEditor.removeFromSidebar': 'Remove from sidebar',
|
||||
'sidebar.projectEditor.removeFromSidebarHint': 'This only removes the project from the sidebar. Its sessions and files remain unchanged.',
|
||||
'sidebar.projectEditor.create': 'Create project',
|
||||
'sidebar.projectEditor.actionFailed': 'Could not save the project changes.',
|
||||
'sidebar.useExistingFolder': 'Use existing folder',
|
||||
'sidebar.chooseProjectFolderUnavailable': 'Folder selection is only available in the desktop app.',
|
||||
'sidebar.projectActions': 'Project actions for {project}',
|
||||
|
||||
@@ -82,6 +82,22 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'sidebar.sortByCreatedAt': '作成日時',
|
||||
'sidebar.sortByUpdatedAt': '更新日時',
|
||||
'sidebar.newBlankProject': '新しい空のプロジェクト',
|
||||
'sidebar.newBlankSession': '新しい空のセッション',
|
||||
'sidebar.projectEditor.createTitle': 'プロジェクトを作成',
|
||||
'sidebar.projectEditor.editTitle': 'プロジェクトを編集',
|
||||
'sidebar.projectEditor.name': 'プロジェクト名',
|
||||
'sidebar.projectEditor.nameHint': '最大 {count} 文字',
|
||||
'sidebar.projectEditor.nameRequired': 'プロジェクト名を入力してください。',
|
||||
'sidebar.projectEditor.nameTooLong': 'プロジェクト名は最大 80 文字です。',
|
||||
'sidebar.projectEditor.sourceFolder': 'ソースフォルダ',
|
||||
'sidebar.projectEditor.sourceFolderRequired': 'ソースフォルダを選択してください。',
|
||||
'sidebar.projectEditor.realPath': '実際のパス',
|
||||
'sidebar.projectEditor.realPathHint': 'このプロジェクトのパスは固定されており、ここでは変更できません。',
|
||||
'sidebar.projectEditor.restoreFolderName': 'フォルダ名に戻す',
|
||||
'sidebar.projectEditor.removeFromSidebar': 'サイドバーから削除',
|
||||
'sidebar.projectEditor.removeFromSidebarHint': 'これはサイドバーからプロジェクトを外すだけです。既存のセッションとファイルは変更されません。',
|
||||
'sidebar.projectEditor.create': 'プロジェクトを作成',
|
||||
'sidebar.projectEditor.actionFailed': 'プロジェクトの変更を保存できませんでした。',
|
||||
'sidebar.useExistingFolder': '既存のフォルダを使用',
|
||||
'sidebar.chooseProjectFolderUnavailable': 'フォルダの選択はデスクトップアプリでのみ利用できます。',
|
||||
'sidebar.projectActions': '{project} のプロジェクト操作',
|
||||
|
||||
@@ -82,6 +82,22 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'sidebar.sortByCreatedAt': '생성 시간',
|
||||
'sidebar.sortByUpdatedAt': '업데이트 시간',
|
||||
'sidebar.newBlankProject': '새 빈 프로젝트',
|
||||
'sidebar.newBlankSession': '새 빈 세션',
|
||||
'sidebar.projectEditor.createTitle': '프로젝트 만들기',
|
||||
'sidebar.projectEditor.editTitle': '프로젝트 편집',
|
||||
'sidebar.projectEditor.name': '프로젝트 이름',
|
||||
'sidebar.projectEditor.nameHint': '최대 {count}자',
|
||||
'sidebar.projectEditor.nameRequired': '프로젝트 이름을 입력하세요.',
|
||||
'sidebar.projectEditor.nameTooLong': '프로젝트 이름은 최대 80자까지 입력할 수 있습니다.',
|
||||
'sidebar.projectEditor.sourceFolder': '원본 폴더',
|
||||
'sidebar.projectEditor.sourceFolderRequired': '원본 폴더를 선택하세요.',
|
||||
'sidebar.projectEditor.realPath': '실제 경로',
|
||||
'sidebar.projectEditor.realPathHint': '이 프로젝트 경로는 고정되어 여기서 변경할 수 없습니다.',
|
||||
'sidebar.projectEditor.restoreFolderName': '폴더 이름으로 복원',
|
||||
'sidebar.projectEditor.removeFromSidebar': '사이드바에서 제거',
|
||||
'sidebar.projectEditor.removeFromSidebarHint': '사이드바에서만 프로젝트를 제거합니다. 기존 세션과 파일은 변경되지 않습니다.',
|
||||
'sidebar.projectEditor.create': '프로젝트 만들기',
|
||||
'sidebar.projectEditor.actionFailed': '프로젝트 변경 사항을 저장하지 못했습니다.',
|
||||
'sidebar.useExistingFolder': '기존 폴더 사용',
|
||||
'sidebar.chooseProjectFolderUnavailable': '폴더 선택은 데스크톱 앱에서만 사용할 수 있습니다.',
|
||||
'sidebar.projectActions': '{project}의 프로젝트 작업',
|
||||
|
||||
@@ -82,6 +82,22 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'sidebar.sortByCreatedAt': '建立時間',
|
||||
'sidebar.sortByUpdatedAt': '更新時間',
|
||||
'sidebar.newBlankProject': '新建空白專案',
|
||||
'sidebar.newBlankSession': '新建空白會話',
|
||||
'sidebar.projectEditor.createTitle': '建立專案',
|
||||
'sidebar.projectEditor.editTitle': '編輯專案',
|
||||
'sidebar.projectEditor.name': '專案名稱',
|
||||
'sidebar.projectEditor.nameHint': '最多 {count} 個字元',
|
||||
'sidebar.projectEditor.nameRequired': '請輸入專案名稱。',
|
||||
'sidebar.projectEditor.nameTooLong': '專案名稱最多 80 個字元。',
|
||||
'sidebar.projectEditor.sourceFolder': '來源資料夾',
|
||||
'sidebar.projectEditor.sourceFolderRequired': '請選擇來源資料夾。',
|
||||
'sidebar.projectEditor.realPath': '真實路徑',
|
||||
'sidebar.projectEditor.realPathHint': '專案真實路徑固定,無法在這裡修改。',
|
||||
'sidebar.projectEditor.restoreFolderName': '恢復資料夾名稱',
|
||||
'sidebar.projectEditor.removeFromSidebar': '從側邊欄移除',
|
||||
'sidebar.projectEditor.removeFromSidebarHint': '這只會從側邊欄移除專案,現有會話和檔案不會改變。',
|
||||
'sidebar.projectEditor.create': '建立專案',
|
||||
'sidebar.projectEditor.actionFailed': '無法儲存專案變更。',
|
||||
'sidebar.useExistingFolder': '使用現有資料夾',
|
||||
'sidebar.chooseProjectFolderUnavailable': '選擇資料夾僅在桌面端可用。',
|
||||
'sidebar.projectActions': '{project} 專案操作',
|
||||
|
||||
@@ -82,6 +82,22 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'sidebar.sortByCreatedAt': '创建时间',
|
||||
'sidebar.sortByUpdatedAt': '更新时间',
|
||||
'sidebar.newBlankProject': '新建空白项目',
|
||||
'sidebar.newBlankSession': '新建空白会话',
|
||||
'sidebar.projectEditor.createTitle': '创建项目',
|
||||
'sidebar.projectEditor.editTitle': '编辑项目',
|
||||
'sidebar.projectEditor.name': '项目名称',
|
||||
'sidebar.projectEditor.nameHint': '最多 {count} 个字符',
|
||||
'sidebar.projectEditor.nameRequired': '请输入项目名称。',
|
||||
'sidebar.projectEditor.nameTooLong': '项目名称最多 80 个字符。',
|
||||
'sidebar.projectEditor.sourceFolder': '源文件夹',
|
||||
'sidebar.projectEditor.sourceFolderRequired': '请选择源文件夹。',
|
||||
'sidebar.projectEditor.realPath': '真实路径',
|
||||
'sidebar.projectEditor.realPathHint': '项目真实路径固定,无法在这里修改。',
|
||||
'sidebar.projectEditor.restoreFolderName': '恢复文件夹名称',
|
||||
'sidebar.projectEditor.removeFromSidebar': '从侧边栏移除',
|
||||
'sidebar.projectEditor.removeFromSidebarHint': '这只会从侧边栏移除项目,已有会话和文件不会改变。',
|
||||
'sidebar.projectEditor.create': '创建项目',
|
||||
'sidebar.projectEditor.actionFailed': '无法保存项目更改。',
|
||||
'sidebar.useExistingFolder': '使用现有文件夹',
|
||||
'sidebar.chooseProjectFolderUnavailable': '选择文件夹仅在桌面端可用。',
|
||||
'sidebar.projectActions': '{project} 项目操作',
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { desktopUiPreferencesApi } from '../api/desktopUiPreferences'
|
||||
import {
|
||||
captureProjectDisplayNameHydrationRevision,
|
||||
hydrateProjectDisplayNames,
|
||||
resetProjectDisplayName,
|
||||
resolveProjectDisplayName,
|
||||
setProjectDisplayName,
|
||||
subscribeProjectDisplayNameChanges,
|
||||
useProjectDisplayNameRevision,
|
||||
} from './projectDisplayNameStore'
|
||||
|
||||
vi.mock('../api/desktopUiPreferences', () => ({
|
||||
desktopUiPreferencesApi: {
|
||||
updateProjectDisplayName: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
const updateProjectDisplayName = vi.mocked(desktopUiPreferencesApi.updateProjectDisplayName)
|
||||
|
||||
beforeEach(() => {
|
||||
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
|
||||
updateProjectDisplayName.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
|
||||
})
|
||||
|
||||
describe('projectDisplayNameStore', () => {
|
||||
it('resolves only the exact project key and not a normalized path variant', async () => {
|
||||
const projectKey = '/workspace/apps/../project'
|
||||
updateProjectDisplayName.mockResolvedValue({ ok: true, projectKey, displayName: 'Project alias' })
|
||||
|
||||
await setProjectDisplayName(projectKey, 'Project alias')
|
||||
|
||||
expect(resolveProjectDisplayName(projectKey)).toBe('Project alias')
|
||||
expect(resolveProjectDisplayName('/workspace/project')).toBeNull()
|
||||
})
|
||||
|
||||
it('notifies subscribers when the resolved display name changes', async () => {
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = subscribeProjectDisplayNameChanges(listener)
|
||||
updateProjectDisplayName.mockResolvedValue({
|
||||
ok: true,
|
||||
projectKey: '/workspace/project',
|
||||
displayName: 'Renamed',
|
||||
})
|
||||
|
||||
await setProjectDisplayName('/workspace/project', 'Renamed')
|
||||
unsubscribe()
|
||||
await setProjectDisplayName('/workspace/project', 'Renamed again')
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('publishes a revision snapshot for consumers that resolve multiple project keys', async () => {
|
||||
const { result } = renderHook(() => useProjectDisplayNameRevision())
|
||||
const initialRevision = result.current
|
||||
updateProjectDisplayName.mockResolvedValue({
|
||||
ok: true,
|
||||
projectKey: '/workspace/project',
|
||||
displayName: 'Renamed',
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await setProjectDisplayName('/workspace/project', 'Renamed')
|
||||
})
|
||||
|
||||
expect(result.current).toBeGreaterThan(initialRevision)
|
||||
})
|
||||
|
||||
it('optimistically updates one key and rolls back only that key after a failed save', async () => {
|
||||
const hydrationRevision = captureProjectDisplayNameHydrationRevision()
|
||||
hydrateProjectDisplayNames({
|
||||
'/workspace/one': 'One',
|
||||
'/workspace/two': 'Two',
|
||||
}, hydrationRevision)
|
||||
updateProjectDisplayName.mockRejectedValueOnce(new Error('save failed'))
|
||||
|
||||
const save = setProjectDisplayName('/workspace/one', 'Renamed one')
|
||||
|
||||
expect(resolveProjectDisplayName('/workspace/one')).toBe('Renamed one')
|
||||
expect(resolveProjectDisplayName('/workspace/two')).toBe('Two')
|
||||
await expect(save).rejects.toThrow('save failed')
|
||||
expect(resolveProjectDisplayName('/workspace/one')).toBe('One')
|
||||
expect(resolveProjectDisplayName('/workspace/two')).toBe('Two')
|
||||
})
|
||||
|
||||
it('does not let an earlier failed mutation roll back a later mutation for the same key', async () => {
|
||||
const firstSave = createDeferred<{ ok: true; projectKey: string; displayName: string | null }>()
|
||||
const secondSave = createDeferred<{ ok: true; projectKey: string; displayName: string | null }>()
|
||||
let secondSaveStarted!: () => void
|
||||
const secondSaveStartedPromise = new Promise<void>((resolve) => {
|
||||
secondSaveStarted = resolve
|
||||
})
|
||||
updateProjectDisplayName
|
||||
.mockImplementationOnce(() => firstSave.promise)
|
||||
.mockImplementationOnce(() => {
|
||||
secondSaveStarted()
|
||||
return secondSave.promise
|
||||
})
|
||||
|
||||
const first = setProjectDisplayName('/workspace/project', 'First')
|
||||
const second = setProjectDisplayName('/workspace/project', 'Second')
|
||||
firstSave.reject(new Error('first save failed'))
|
||||
|
||||
await expect(first).rejects.toThrow('first save failed')
|
||||
await secondSaveStartedPromise
|
||||
expect(resolveProjectDisplayName('/workspace/project')).toBe('Second')
|
||||
|
||||
secondSave.resolve({ ok: true, projectKey: '/workspace/project', displayName: 'Second' })
|
||||
await expect(second).resolves.toBeUndefined()
|
||||
expect(resolveProjectDisplayName('/workspace/project')).toBe('Second')
|
||||
})
|
||||
|
||||
it('keeps a post-request local mutation when a stale preference response hydrates', async () => {
|
||||
const hydrationRevision = captureProjectDisplayNameHydrationRevision()
|
||||
const saveResponse = createDeferred<{ ok: true; projectKey: string; displayName: string | null }>()
|
||||
updateProjectDisplayName.mockReturnValueOnce(saveResponse.promise)
|
||||
|
||||
const save = setProjectDisplayName('/workspace/project', 'Local name')
|
||||
hydrateProjectDisplayNames({
|
||||
'/workspace/project': 'Stale server name',
|
||||
'/workspace/other': 'Other server name',
|
||||
}, hydrationRevision)
|
||||
|
||||
expect(resolveProjectDisplayName('/workspace/project')).toBe('Local name')
|
||||
expect(resolveProjectDisplayName('/workspace/other')).toBe('Other server name')
|
||||
|
||||
saveResponse.resolve({ ok: true, projectKey: '/workspace/project', displayName: 'Local name' })
|
||||
await save
|
||||
})
|
||||
|
||||
it('keeps a mutation that settles after preference hydration starts', async () => {
|
||||
const projectKey = '/workspace/project'
|
||||
const saveResponse = createDeferred<{ ok: true; projectKey: string; displayName: string | null }>()
|
||||
updateProjectDisplayName.mockReturnValueOnce(saveResponse.promise)
|
||||
|
||||
const save = setProjectDisplayName(projectKey, 'Local name')
|
||||
const hydrationRevision = captureProjectDisplayNameHydrationRevision()
|
||||
saveResponse.resolve({ ok: true, projectKey, displayName: 'Local name' })
|
||||
await save
|
||||
|
||||
hydrateProjectDisplayNames({ [projectKey]: 'Stale server name' }, hydrationRevision)
|
||||
expect(resolveProjectDisplayName(projectKey)).toBe('Local name')
|
||||
|
||||
const nextHydrationRevision = captureProjectDisplayNameHydrationRevision()
|
||||
hydrateProjectDisplayNames({ [projectKey]: 'New server name' }, nextHydrationRevision)
|
||||
expect(resolveProjectDisplayName(projectKey)).toBe('New server name')
|
||||
})
|
||||
|
||||
it('resets a project display name through the shared update endpoint', async () => {
|
||||
const projectKey = '/workspace/project'
|
||||
const hydrationRevision = captureProjectDisplayNameHydrationRevision()
|
||||
hydrateProjectDisplayNames({ [projectKey]: 'Custom name' }, hydrationRevision)
|
||||
updateProjectDisplayName.mockResolvedValue({ ok: true, projectKey, displayName: null })
|
||||
|
||||
const reset = resetProjectDisplayName(projectKey)
|
||||
|
||||
expect(resolveProjectDisplayName(projectKey)).toBeNull()
|
||||
await expect(reset).resolves.toBeUndefined()
|
||||
expect(updateProjectDisplayName).toHaveBeenCalledWith(projectKey, null)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { desktopUiPreferencesApi } from '../api/desktopUiPreferences'
|
||||
|
||||
export type ProjectDisplayNames = Record<string, string>
|
||||
|
||||
type DisplayName = string | null
|
||||
|
||||
type PendingMutation = {
|
||||
displayName: DisplayName
|
||||
}
|
||||
|
||||
type Listener = () => void
|
||||
|
||||
const resolvedDisplayNames = new Map<string, string>()
|
||||
const confirmedDisplayNames = new Map<string, string>()
|
||||
const confirmedMutationRevisions = new Map<string, number>()
|
||||
const pendingMutations = new Map<string, PendingMutation[]>()
|
||||
const writeQueues = new Map<string, Promise<void>>()
|
||||
const listeners = new Set<Listener>()
|
||||
|
||||
let hydrationBarrierRevision = 0
|
||||
let snapshotRevision = 0
|
||||
|
||||
export function resolveProjectDisplayName(projectKey: string): string | null {
|
||||
return resolvedDisplayNames.get(projectKey) ?? null
|
||||
}
|
||||
|
||||
export function subscribeProjectDisplayNameChanges(listener: Listener): () => void {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
|
||||
export function useProjectDisplayName(projectKey: string): string | null {
|
||||
return useSyncExternalStore(
|
||||
subscribeProjectDisplayNameChanges,
|
||||
() => resolveProjectDisplayName(projectKey),
|
||||
() => resolveProjectDisplayName(projectKey),
|
||||
)
|
||||
}
|
||||
|
||||
export function useProjectDisplayNameRevision(): number {
|
||||
return useSyncExternalStore(
|
||||
subscribeProjectDisplayNameChanges,
|
||||
() => snapshotRevision,
|
||||
() => snapshotRevision,
|
||||
)
|
||||
}
|
||||
|
||||
export function captureProjectDisplayNameHydrationRevision(): number {
|
||||
return ++hydrationBarrierRevision
|
||||
}
|
||||
|
||||
export function hydrateProjectDisplayNames(
|
||||
projectDisplayNames: Readonly<ProjectDisplayNames>,
|
||||
hydrationRevision: number,
|
||||
): void {
|
||||
const hydratedDisplayNames = new Map(Object.entries(projectDisplayNames))
|
||||
const projectKeys = new Set<string>([
|
||||
...resolvedDisplayNames.keys(),
|
||||
...confirmedDisplayNames.keys(),
|
||||
...confirmedMutationRevisions.keys(),
|
||||
...pendingMutations.keys(),
|
||||
...hydratedDisplayNames.keys(),
|
||||
])
|
||||
|
||||
let changed = false
|
||||
for (const projectKey of projectKeys) {
|
||||
const confirmedMutationRevision = confirmedMutationRevisions.get(projectKey)
|
||||
if (confirmedMutationRevision === undefined || confirmedMutationRevision < hydrationRevision) {
|
||||
setDisplayName(confirmedDisplayNames, projectKey, hydratedDisplayNames.get(projectKey) ?? null)
|
||||
confirmedMutationRevisions.delete(projectKey)
|
||||
}
|
||||
changed = syncResolvedDisplayName(projectKey) || changed
|
||||
}
|
||||
|
||||
if (changed) notifyListeners()
|
||||
}
|
||||
|
||||
export function setProjectDisplayName(projectKey: string, displayName: string): Promise<void> {
|
||||
return persistProjectDisplayName(projectKey, displayName)
|
||||
}
|
||||
|
||||
export function resetProjectDisplayName(projectKey: string): Promise<void> {
|
||||
return persistProjectDisplayName(projectKey, null)
|
||||
}
|
||||
|
||||
function persistProjectDisplayName(projectKey: string, displayName: DisplayName): Promise<void> {
|
||||
const mutation: PendingMutation = { displayName }
|
||||
const mutations = pendingMutations.get(projectKey) ?? []
|
||||
mutations.push(mutation)
|
||||
pendingMutations.set(projectKey, mutations)
|
||||
|
||||
if (syncResolvedDisplayName(projectKey)) notifyListeners()
|
||||
|
||||
const runMutation = async () => {
|
||||
try {
|
||||
const response = await desktopUiPreferencesApi.updateProjectDisplayName(projectKey, displayName)
|
||||
settleMutation(projectKey, mutation, response.displayName)
|
||||
} catch (error) {
|
||||
settleMutation(projectKey, mutation)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const previousWrite = writeQueues.get(projectKey)
|
||||
const request = previousWrite ? previousWrite.then(runMutation) : runMutation()
|
||||
const queuedWrite = request.catch(() => undefined)
|
||||
writeQueues.set(projectKey, queuedWrite)
|
||||
void queuedWrite.then(() => {
|
||||
if (writeQueues.get(projectKey) === queuedWrite) {
|
||||
writeQueues.delete(projectKey)
|
||||
}
|
||||
})
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
function settleMutation(
|
||||
projectKey: string,
|
||||
mutation: PendingMutation,
|
||||
confirmedDisplayName?: DisplayName,
|
||||
): void {
|
||||
const mutations = pendingMutations.get(projectKey)
|
||||
const mutationIndex = mutations?.indexOf(mutation) ?? -1
|
||||
if (mutationIndex === -1 || !mutations) return
|
||||
|
||||
mutations.splice(mutationIndex, 1)
|
||||
if (mutations.length === 0) {
|
||||
pendingMutations.delete(projectKey)
|
||||
}
|
||||
|
||||
if (confirmedDisplayName !== undefined) {
|
||||
setDisplayName(confirmedDisplayNames, projectKey, confirmedDisplayName)
|
||||
confirmedMutationRevisions.set(projectKey, ++hydrationBarrierRevision)
|
||||
}
|
||||
|
||||
if (syncResolvedDisplayName(projectKey)) notifyListeners()
|
||||
}
|
||||
|
||||
function syncResolvedDisplayName(projectKey: string): boolean {
|
||||
const mutations = pendingMutations.get(projectKey)
|
||||
const latestMutation = mutations?.[mutations.length - 1]
|
||||
const displayName = latestMutation
|
||||
? latestMutation.displayName
|
||||
: confirmedDisplayNames.get(projectKey) ?? null
|
||||
const currentDisplayName = resolvedDisplayNames.get(projectKey) ?? null
|
||||
if (currentDisplayName === displayName) return false
|
||||
|
||||
setDisplayName(resolvedDisplayNames, projectKey, displayName)
|
||||
return true
|
||||
}
|
||||
|
||||
function setDisplayName(target: Map<string, string>, projectKey: string, displayName: DisplayName): void {
|
||||
if (displayName === null) {
|
||||
target.delete(projectKey)
|
||||
} else {
|
||||
target.set(projectKey, displayName)
|
||||
}
|
||||
}
|
||||
|
||||
function notifyListeners(): void {
|
||||
snapshotRevision += 1
|
||||
for (const listener of listeners) listener()
|
||||
}
|
||||
@@ -12,6 +12,16 @@ const checks: Check[] = [
|
||||
title: 'Server persistent JSON migrations',
|
||||
command: ['bun', 'test', './src/server/__tests__/persistence-upgrade.test.ts'],
|
||||
},
|
||||
{
|
||||
title: 'Desktop UI preference migrations',
|
||||
command: [
|
||||
'bun',
|
||||
'test',
|
||||
'./src/server/__tests__/desktop-ui-preferences.test.ts',
|
||||
'--test-name-pattern',
|
||||
'normalizes old schema files',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Desktop localStorage migrations',
|
||||
command: ['bun', 'run', 'test', '--', '--run', 'src/lib/persistenceMigrations.test.ts'],
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('DesktopUiPreferencesService', () => {
|
||||
|
||||
expect(result.exists).toBe(false)
|
||||
expect(result.preferences).toEqual({
|
||||
schemaVersion: 4,
|
||||
schemaVersion: 5,
|
||||
profile: {
|
||||
displayName: 'cc-haha',
|
||||
subtitle: 'github.com/NanmiCoder/cc-haha',
|
||||
@@ -74,6 +74,7 @@ describe('DesktopUiPreferencesService', () => {
|
||||
avatarUpdatedAt: null,
|
||||
},
|
||||
pet: DEFAULT_PET_PREFERENCES,
|
||||
projectDisplayNames: {},
|
||||
sidebar: {
|
||||
projectOrder: [],
|
||||
pinnedProjects: [],
|
||||
@@ -89,13 +90,22 @@ describe('DesktopUiPreferencesService', () => {
|
||||
await fs.writeFile(
|
||||
path.join(tmpDir, 'cc-haha', 'desktop-ui.json'),
|
||||
JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
schemaVersion: 4,
|
||||
futureField: { keep: true },
|
||||
sidebar: {
|
||||
projectOrder: ['/workspace/alpha', 42, '/workspace/alpha', '/workspace/beta'],
|
||||
pinnedProjects: ['/workspace/beta'],
|
||||
hiddenProjects: [null, '/workspace/gamma'],
|
||||
},
|
||||
projectDisplayNames: {
|
||||
'/workspace/alpha': ' Alpha project ',
|
||||
'/workspace/beta': 42,
|
||||
'': 'Missing key',
|
||||
' ': 'Whitespace key',
|
||||
'/workspace/blank': ' ',
|
||||
'/workspace/too-long-name': 'n'.repeat(81),
|
||||
['x'.repeat(4_097)]: 'Overlong key',
|
||||
},
|
||||
}),
|
||||
'utf-8',
|
||||
)
|
||||
@@ -110,7 +120,7 @@ describe('DesktopUiPreferencesService', () => {
|
||||
|
||||
expect(before.exists).toBe(true)
|
||||
expect(before.preferences).toEqual({
|
||||
schemaVersion: 4,
|
||||
schemaVersion: 5,
|
||||
futureField: { keep: true },
|
||||
profile: {
|
||||
displayName: 'cc-haha',
|
||||
@@ -119,6 +129,9 @@ describe('DesktopUiPreferencesService', () => {
|
||||
avatarUpdatedAt: null,
|
||||
},
|
||||
pet: DEFAULT_PET_PREFERENCES,
|
||||
projectDisplayNames: {
|
||||
'/workspace/alpha': 'Alpha project',
|
||||
},
|
||||
sidebar: {
|
||||
projectOrder: ['/workspace/alpha', '/workspace/beta'],
|
||||
pinnedProjects: ['/workspace/beta'],
|
||||
@@ -128,7 +141,7 @@ describe('DesktopUiPreferencesService', () => {
|
||||
},
|
||||
})
|
||||
expect(after).toEqual({
|
||||
schemaVersion: 4,
|
||||
schemaVersion: 5,
|
||||
futureField: { keep: true },
|
||||
profile: {
|
||||
displayName: 'cc-haha',
|
||||
@@ -137,6 +150,9 @@ describe('DesktopUiPreferencesService', () => {
|
||||
avatarUpdatedAt: null,
|
||||
},
|
||||
pet: DEFAULT_PET_PREFERENCES,
|
||||
projectDisplayNames: {
|
||||
'/workspace/alpha': 'Alpha project',
|
||||
},
|
||||
sidebar: {
|
||||
projectOrder: ['/workspace/gamma'],
|
||||
pinnedProjects: [],
|
||||
@@ -160,9 +176,79 @@ describe('DesktopUiPreferencesService', () => {
|
||||
expect(result.preferences.sidebar.hiddenProjects).toEqual([])
|
||||
expect(result.preferences.profile.displayName).toBe('cc-haha')
|
||||
expect(result.preferences.pet).toEqual(DEFAULT_PET_PREFERENCES)
|
||||
expect(result.preferences.projectDisplayNames).toEqual({})
|
||||
expect(files.some((name) => name.startsWith('desktop-ui.json.invalid-'))).toBe(true)
|
||||
})
|
||||
|
||||
test('sets and resets exact project display name keys without prototype pollution', async () => {
|
||||
const service = new DesktopUiPreferencesService()
|
||||
const projectKey = ' /workspace/alpha '
|
||||
|
||||
await expect(service.updateProjectDisplayName({
|
||||
projectKey,
|
||||
displayName: ' Alpha project ',
|
||||
})).resolves.toEqual({
|
||||
projectKey,
|
||||
displayName: 'Alpha project',
|
||||
})
|
||||
await service.updateProjectDisplayName({ projectKey: '__proto__', displayName: 'Prototype project' })
|
||||
await service.updateProjectDisplayName({ projectKey: 'constructor', displayName: 'Constructor project' })
|
||||
|
||||
const { preferences } = await service.readPreferences()
|
||||
expect(Object.getPrototypeOf(preferences.projectDisplayNames)).toBe(Object.prototype)
|
||||
expect(Object.entries(preferences.projectDisplayNames)).toEqual([
|
||||
[projectKey, 'Alpha project'],
|
||||
['__proto__', 'Prototype project'],
|
||||
['constructor', 'Constructor project'],
|
||||
])
|
||||
expect(Object.hasOwn(preferences.projectDisplayNames, '__proto__')).toBe(true)
|
||||
|
||||
await expect(service.updateProjectDisplayName({ projectKey, displayName: null })).resolves.toEqual({
|
||||
projectKey,
|
||||
displayName: null,
|
||||
})
|
||||
expect(Object.entries((await service.readPreferences()).preferences.projectDisplayNames)).toEqual([
|
||||
['__proto__', 'Prototype project'],
|
||||
['constructor', 'Constructor project'],
|
||||
])
|
||||
})
|
||||
|
||||
test('merges concurrent project display name changes for different keys', async () => {
|
||||
const firstRenderer = new DesktopUiPreferencesService()
|
||||
const secondRenderer = new DesktopUiPreferencesService()
|
||||
|
||||
await Promise.all([
|
||||
firstRenderer.updateProjectDisplayName({
|
||||
projectKey: '/workspace/alpha',
|
||||
displayName: 'Alpha',
|
||||
}),
|
||||
secondRenderer.updateProjectDisplayName({
|
||||
projectKey: '/workspace/beta',
|
||||
displayName: 'Beta',
|
||||
}),
|
||||
])
|
||||
|
||||
expect((await firstRenderer.readPreferences()).preferences.projectDisplayNames).toEqual({
|
||||
'/workspace/alpha': 'Alpha',
|
||||
'/workspace/beta': 'Beta',
|
||||
})
|
||||
})
|
||||
|
||||
test('preserves project display names when updating sidebar, profile, and pet preferences', async () => {
|
||||
const service = new DesktopUiPreferencesService()
|
||||
await service.updateProjectDisplayName({
|
||||
projectKey: '/workspace/alpha',
|
||||
displayName: 'Alpha',
|
||||
})
|
||||
await service.updateSidebarPreferences({ projectOrder: ['/workspace/alpha'] })
|
||||
await service.updateProfilePreferences({ displayName: 'Local Operator' })
|
||||
await service.updatePetPreferences({ collapsed: true })
|
||||
|
||||
expect((await service.readPreferences()).preferences.projectDisplayNames).toEqual({
|
||||
'/workspace/alpha': 'Alpha',
|
||||
})
|
||||
})
|
||||
|
||||
test('normalizes invalid pet preferences while preserving unrelated fields', async () => {
|
||||
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
|
||||
await fs.writeFile(
|
||||
@@ -221,7 +307,7 @@ describe('DesktopUiPreferencesService', () => {
|
||||
})
|
||||
|
||||
expect(after).toMatchObject({
|
||||
schemaVersion: 4,
|
||||
schemaVersion: 5,
|
||||
futureField: { keep: true },
|
||||
profile: {
|
||||
displayName: 'Local Operator',
|
||||
@@ -300,7 +386,7 @@ describe('DesktopUiPreferencesService', () => {
|
||||
const after = await new DesktopUiPreferencesService().updatePetPreferences({ enabled: true })
|
||||
|
||||
expect(after).toMatchObject({
|
||||
schemaVersion: 4,
|
||||
schemaVersion: 5,
|
||||
futureField: { keep: true },
|
||||
pet: {
|
||||
futurePetField: { keep: 'pet-too' },
|
||||
@@ -343,6 +429,9 @@ describe('DesktopUiPreferencesService', () => {
|
||||
...DEFAULT_PET_PREFERENCES,
|
||||
futurePet: { keep: 'pet' },
|
||||
},
|
||||
projectDisplayNames: {
|
||||
'/workspace/future': 'Future project',
|
||||
},
|
||||
}),
|
||||
'utf-8',
|
||||
)
|
||||
@@ -358,6 +447,9 @@ describe('DesktopUiPreferencesService', () => {
|
||||
enabled: true,
|
||||
futurePet: { keep: 'pet' },
|
||||
},
|
||||
projectDisplayNames: {
|
||||
'/workspace/future': 'Future project',
|
||||
},
|
||||
})
|
||||
expect(await readDesktopUiFile()).toEqual(after)
|
||||
|
||||
@@ -376,6 +468,7 @@ describe('DesktopUiPreferencesService', () => {
|
||||
},
|
||||
profile: { futureProfile: { keep: 'profile' } },
|
||||
pet: { futurePet: { keep: 'pet' } },
|
||||
projectDisplayNames: { '/workspace/future': 'Future project' },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -389,7 +482,7 @@ describe('DesktopUiPreferencesService', () => {
|
||||
})
|
||||
|
||||
expect(after).toEqual({
|
||||
schemaVersion: 4,
|
||||
schemaVersion: 5,
|
||||
profile: {
|
||||
displayName: 'Claude Captain',
|
||||
subtitle: 'local.example/profile',
|
||||
@@ -397,6 +490,7 @@ describe('DesktopUiPreferencesService', () => {
|
||||
avatarUpdatedAt: null,
|
||||
},
|
||||
pet: DEFAULT_PET_PREFERENCES,
|
||||
projectDisplayNames: {},
|
||||
sidebar: {
|
||||
projectOrder: [],
|
||||
pinnedProjects: [],
|
||||
@@ -481,7 +575,7 @@ describe('desktop UI preferences API', () => {
|
||||
expect(putBody).toEqual({
|
||||
ok: true,
|
||||
preferences: {
|
||||
schemaVersion: 4,
|
||||
schemaVersion: 5,
|
||||
profile: {
|
||||
displayName: 'cc-haha',
|
||||
subtitle: 'github.com/NanmiCoder/cc-haha',
|
||||
@@ -489,6 +583,7 @@ describe('desktop UI preferences API', () => {
|
||||
avatarUpdatedAt: null,
|
||||
},
|
||||
pet: DEFAULT_PET_PREFERENCES,
|
||||
projectDisplayNames: {},
|
||||
sidebar: {
|
||||
projectOrder: ['/workspace/beta', '/workspace/alpha'],
|
||||
pinnedProjects: ['/workspace/beta'],
|
||||
@@ -507,7 +602,7 @@ describe('desktop UI preferences API', () => {
|
||||
expect(getBody).toEqual({
|
||||
exists: true,
|
||||
preferences: {
|
||||
schemaVersion: 4,
|
||||
schemaVersion: 5,
|
||||
profile: {
|
||||
displayName: 'cc-haha',
|
||||
subtitle: 'github.com/NanmiCoder/cc-haha',
|
||||
@@ -515,6 +610,7 @@ describe('desktop UI preferences API', () => {
|
||||
avatarUpdatedAt: null,
|
||||
},
|
||||
pet: DEFAULT_PET_PREFERENCES,
|
||||
projectDisplayNames: {},
|
||||
sidebar: {
|
||||
projectOrder: ['/workspace/beta', '/workspace/alpha'],
|
||||
pinnedProjects: ['/workspace/beta'],
|
||||
@@ -526,6 +622,67 @@ describe('desktop UI preferences API', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('sets and resets a project display name through the API without canonicalizing its key', async () => {
|
||||
const projectKey = ' /workspace/alpha '
|
||||
const setReq = makeRequest('PUT', '/api/desktop-ui/preferences/project-display-name', {
|
||||
projectKey,
|
||||
displayName: ' Alpha project ',
|
||||
})
|
||||
|
||||
const setRes = await handleDesktopUiApi(setReq.req, setReq.url, setReq.segments)
|
||||
expect(setRes.status).toBe(200)
|
||||
await expect(setRes.json()).resolves.toEqual({
|
||||
ok: true,
|
||||
projectKey,
|
||||
displayName: 'Alpha project',
|
||||
})
|
||||
expect((await new DesktopUiPreferencesService().readPreferences()).preferences.projectDisplayNames).toEqual({
|
||||
[projectKey]: 'Alpha project',
|
||||
})
|
||||
|
||||
const resetReq = makeRequest('PUT', '/api/desktop-ui/preferences/project-display-name', {
|
||||
projectKey,
|
||||
displayName: null,
|
||||
})
|
||||
const resetRes = await handleDesktopUiApi(resetReq.req, resetReq.url, resetReq.segments)
|
||||
expect(resetRes.status).toBe(200)
|
||||
await expect(resetRes.json()).resolves.toEqual({
|
||||
ok: true,
|
||||
projectKey,
|
||||
displayName: null,
|
||||
})
|
||||
expect((await new DesktopUiPreferencesService().readPreferences()).preferences.projectDisplayNames).toEqual({})
|
||||
})
|
||||
|
||||
test('rejects invalid project display name updates without overwriting stored names', async () => {
|
||||
const validReq = makeRequest('PUT', '/api/desktop-ui/preferences/project-display-name', {
|
||||
projectKey: '/workspace/alpha',
|
||||
displayName: 'Alpha',
|
||||
})
|
||||
expect((await handleDesktopUiApi(validReq.req, validReq.url, validReq.segments)).status).toBe(200)
|
||||
|
||||
for (const [body, message] of [
|
||||
[{ projectKey: '', displayName: 'Replacement' }, 'projectKey must be a non-empty string up to 4096 characters'],
|
||||
[{ projectKey: ' ', displayName: 'Replacement' }, 'projectKey must be a non-empty string up to 4096 characters'],
|
||||
[{ projectKey: '/workspace/alpha', displayName: ' ' }, 'displayName must be a non-empty string up to 80 characters'],
|
||||
[{ projectKey: '/workspace/alpha', displayName: 42 }, 'displayName must be a non-empty string up to 80 characters'],
|
||||
[{ projectKey: '/workspace/alpha', displayName: 'n'.repeat(81) }, 'displayName must be a non-empty string up to 80 characters'],
|
||||
[{ projectKey: 'x'.repeat(4_097), displayName: 'Replacement' }, 'projectKey must be a non-empty string up to 4096 characters'],
|
||||
] as const) {
|
||||
const invalidReq = makeRequest('PUT', '/api/desktop-ui/preferences/project-display-name', body)
|
||||
const invalidRes = await handleDesktopUiApi(invalidReq.req, invalidReq.url, invalidReq.segments)
|
||||
expect(invalidRes.status).toBe(400)
|
||||
await expect(invalidRes.json()).resolves.toMatchObject({
|
||||
error: 'BAD_REQUEST',
|
||||
message,
|
||||
})
|
||||
}
|
||||
|
||||
expect((await new DesktopUiPreferencesService().readPreferences()).preferences.projectDisplayNames).toEqual({
|
||||
'/workspace/alpha': 'Alpha',
|
||||
})
|
||||
})
|
||||
|
||||
test('persists normalized pet preferences through the API', async () => {
|
||||
const putReq = makeRequest('PUT', '/api/desktop-ui/preferences/pet', {
|
||||
enabled: true,
|
||||
@@ -543,7 +700,7 @@ describe('desktop UI preferences API', () => {
|
||||
await expect(putRes.json()).resolves.toMatchObject({
|
||||
ok: true,
|
||||
preferences: {
|
||||
schemaVersion: 4,
|
||||
schemaVersion: 5,
|
||||
pet: {
|
||||
enabled: true,
|
||||
selectedPetId: 'seedy',
|
||||
|
||||
@@ -568,6 +568,7 @@ describe('remote H5 auth and CORS integration', () => {
|
||||
for (const [method, pathName] of [
|
||||
['GET', '/api/providers'],
|
||||
['GET', '/api/desktop-ui/preferences'],
|
||||
['PUT', '/api/desktop-ui/preferences/project-display-name'],
|
||||
['GET', '/api/filesystem'],
|
||||
['GET', '/api/computer-use/authorized-apps'],
|
||||
['GET', '/api/settings/user'],
|
||||
|
||||
@@ -32,6 +32,7 @@ describe('pet access capability policy', () => {
|
||||
for (const [method, pathname] of [
|
||||
['GET', '/api/providers'],
|
||||
['GET', '/api/desktop-ui/preferences'],
|
||||
['PUT', '/api/desktop-ui/preferences/project-display-name'],
|
||||
['GET', '/api/filesystem'],
|
||||
['GET', '/api/computer-use/authorized-apps'],
|
||||
['GET', '/api/settings/user'],
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*
|
||||
* GET /api/desktop-ui/preferences — read cc-haha UI preferences
|
||||
* PUT /api/desktop-ui/preferences/sidebar — persist sidebar project preferences
|
||||
* PUT /api/desktop-ui/preferences/project-display-name — set or reset one project display name
|
||||
* PUT /api/desktop-ui/preferences/profile — persist local profile preferences
|
||||
* GET /api/desktop-ui/preferences/pet — read only desktop pet preferences
|
||||
* PUT /api/desktop-ui/preferences/pet — patch desktop pet preferences
|
||||
@@ -46,6 +47,13 @@ export async function handleDesktopUiApi(
|
||||
})
|
||||
}
|
||||
|
||||
if (detail === 'project-display-name') {
|
||||
if (req.method !== 'PUT') throw methodNotAllowed(req.method)
|
||||
const body = await parseJsonBody(req)
|
||||
const update = await desktopUiPreferencesService.updateProjectDisplayName(body)
|
||||
return Response.json({ ok: true, ...update })
|
||||
}
|
||||
|
||||
if (detail === 'pet') {
|
||||
if (req.method === 'GET') {
|
||||
const result = await desktopUiPreferencesService.readPreferences()
|
||||
|
||||
@@ -6,8 +6,11 @@ import { ApiError } from '../middleware/errorHandler.js'
|
||||
import { readRecoverableJsonFile } from './recoverableJsonFile.js'
|
||||
import { ensurePersistentStorageUpgraded } from './persistentStorageMigrations.js'
|
||||
|
||||
const CURRENT_DESKTOP_UI_PREFERENCES_SCHEMA_VERSION = 4
|
||||
const CURRENT_DESKTOP_UI_PREFERENCES_SCHEMA_VERSION = 5
|
||||
const MAX_PROJECT_PREFERENCE_ENTRIES = 2_000
|
||||
const MAX_PROJECT_DISPLAY_NAME_ENTRIES = 2_000
|
||||
const MAX_PROJECT_DISPLAY_NAME_KEY_LENGTH = 4_096
|
||||
const MAX_PROJECT_DISPLAY_NAME_LENGTH = 80
|
||||
const MAX_PROFILE_DISPLAY_NAME_LENGTH = 80
|
||||
const MAX_PROFILE_SUBTITLE_LENGTH = 160
|
||||
const MAX_PROFILE_AVATAR_BYTES = 2_000_000
|
||||
@@ -50,11 +53,14 @@ export type DesktopPetPreferences = {
|
||||
lastSessionId: string | null
|
||||
}
|
||||
|
||||
export type ProjectDisplayNames = Record<string, string>
|
||||
|
||||
export type DesktopUiPreferences = {
|
||||
schemaVersion: number
|
||||
sidebar: SidebarProjectPreferences
|
||||
profile: DesktopProfilePreferences
|
||||
pet: DesktopPetPreferences
|
||||
projectDisplayNames: ProjectDisplayNames
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
@@ -88,12 +94,17 @@ const DEFAULT_PET_PREFERENCES: DesktopPetPreferences = {
|
||||
lastSessionId: null,
|
||||
}
|
||||
|
||||
function createProjectDisplayNames(): ProjectDisplayNames {
|
||||
return Object.fromEntries([]) as ProjectDisplayNames
|
||||
}
|
||||
|
||||
function defaultPreferences(): DesktopUiPreferences {
|
||||
return {
|
||||
schemaVersion: CURRENT_DESKTOP_UI_PREFERENCES_SCHEMA_VERSION,
|
||||
sidebar: { ...DEFAULT_SIDEBAR_PROJECT_PREFERENCES },
|
||||
profile: { ...DEFAULT_PROFILE_PREFERENCES },
|
||||
pet: { ...DEFAULT_PET_PREFERENCES },
|
||||
projectDisplayNames: createProjectDisplayNames(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +123,81 @@ function normalizeStringArray(value: unknown): string[] {
|
||||
return normalized
|
||||
}
|
||||
|
||||
function isValidProjectDisplayNameKey(value: unknown): value is string {
|
||||
return typeof value === 'string' &&
|
||||
value.length > 0 &&
|
||||
value.trim().length > 0 &&
|
||||
value.length <= MAX_PROJECT_DISPLAY_NAME_KEY_LENGTH
|
||||
}
|
||||
|
||||
function normalizeProjectDisplayName(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
const normalized = value.trim().replace(/\s+/g, ' ')
|
||||
if (normalized.length === 0 || normalized.length > MAX_PROJECT_DISPLAY_NAME_LENGTH) {
|
||||
return null
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function setProjectDisplayName(
|
||||
projectDisplayNames: ProjectDisplayNames,
|
||||
projectKey: string,
|
||||
displayName: string,
|
||||
): void {
|
||||
Object.defineProperty(projectDisplayNames, projectKey, {
|
||||
value: displayName,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function normalizeProjectDisplayNames(value: unknown): ProjectDisplayNames {
|
||||
const normalized = createProjectDisplayNames()
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return normalized
|
||||
|
||||
let entryCount = 0
|
||||
for (const [projectKey, displayName] of Object.entries(value)) {
|
||||
if (!isValidProjectDisplayNameKey(projectKey)) continue
|
||||
const normalizedDisplayName = normalizeProjectDisplayName(displayName)
|
||||
if (!normalizedDisplayName) continue
|
||||
|
||||
setProjectDisplayName(normalized, projectKey, normalizedDisplayName)
|
||||
entryCount += 1
|
||||
if (entryCount >= MAX_PROJECT_DISPLAY_NAME_ENTRIES) break
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function validateProjectDisplayNameUpdate(value: unknown): {
|
||||
projectKey: string
|
||||
displayName: string | null
|
||||
} {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw ApiError.badRequest('Project display name update must be an object')
|
||||
}
|
||||
|
||||
const patch = value as Record<string, unknown>
|
||||
if (!isValidProjectDisplayNameKey(patch.projectKey)) {
|
||||
throw ApiError.badRequest(
|
||||
`projectKey must be a non-empty string up to ${MAX_PROJECT_DISPLAY_NAME_KEY_LENGTH} characters`,
|
||||
)
|
||||
}
|
||||
if (patch.displayName === null) {
|
||||
return { projectKey: patch.projectKey, displayName: null }
|
||||
}
|
||||
|
||||
const displayName = normalizeProjectDisplayName(patch.displayName)
|
||||
if (!displayName) {
|
||||
throw ApiError.badRequest(
|
||||
`displayName must be a non-empty string up to ${MAX_PROJECT_DISPLAY_NAME_LENGTH} characters`,
|
||||
)
|
||||
}
|
||||
|
||||
return { projectKey: patch.projectKey, displayName }
|
||||
}
|
||||
|
||||
export function normalizeSidebarProjectPreferences(value: unknown): SidebarProjectPreferences {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return { ...DEFAULT_SIDEBAR_PROJECT_PREFERENCES }
|
||||
@@ -256,6 +342,7 @@ function normalizeDesktopUiPreferences(value: unknown): DesktopUiPreferences | n
|
||||
sidebar: normalizeSidebarProjectPreferences(record.sidebar),
|
||||
profile: normalizeProfilePreferences(record.profile),
|
||||
pet: normalizeDesktopPetPreferences(record.pet),
|
||||
projectDisplayNames: normalizeProjectDisplayNames(record.projectDisplayNames),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,6 +455,7 @@ export class DesktopUiPreferencesService {
|
||||
}),
|
||||
profile: normalizeProfilePreferences(preferences.profile),
|
||||
pet: normalizeDesktopPetPreferences(preferences.pet),
|
||||
projectDisplayNames: normalizeProjectDisplayNames(preferences.projectDisplayNames),
|
||||
}
|
||||
|
||||
await this.writePreferences(nextPreferences)
|
||||
@@ -400,6 +488,7 @@ export class DesktopUiPreferencesService {
|
||||
avatarUpdatedAt: currentProfile.avatarUpdatedAt,
|
||||
},
|
||||
pet: normalizeDesktopPetPreferences(preferences.pet),
|
||||
projectDisplayNames: normalizeProjectDisplayNames(preferences.projectDisplayNames),
|
||||
}
|
||||
|
||||
await this.writePreferences(nextPreferences)
|
||||
@@ -407,6 +496,43 @@ export class DesktopUiPreferencesService {
|
||||
})
|
||||
}
|
||||
|
||||
async updateProjectDisplayName(value: unknown): Promise<{
|
||||
projectKey: string
|
||||
displayName: string | null
|
||||
}> {
|
||||
const update = validateProjectDisplayNameUpdate(value)
|
||||
const filePath = this.getPreferencesPath()
|
||||
return this.withWriteLock(filePath, async () => {
|
||||
const { preferences } = await this.readPreferences()
|
||||
const projectDisplayNames = normalizeProjectDisplayNames(preferences.projectDisplayNames)
|
||||
if (update.displayName === null) {
|
||||
delete projectDisplayNames[update.projectKey]
|
||||
} else {
|
||||
if (
|
||||
!Object.hasOwn(projectDisplayNames, update.projectKey) &&
|
||||
Object.keys(projectDisplayNames).length >= MAX_PROJECT_DISPLAY_NAME_ENTRIES
|
||||
) {
|
||||
throw ApiError.badRequest(
|
||||
`Project display names cannot exceed ${MAX_PROJECT_DISPLAY_NAME_ENTRIES} entries`,
|
||||
)
|
||||
}
|
||||
setProjectDisplayName(projectDisplayNames, update.projectKey, update.displayName)
|
||||
}
|
||||
|
||||
const nextPreferences: DesktopUiPreferences = {
|
||||
...preferences,
|
||||
schemaVersion: preferences.schemaVersion,
|
||||
sidebar: normalizeSidebarProjectPreferences(preferences.sidebar),
|
||||
profile: normalizeProfilePreferences(preferences.profile),
|
||||
pet: normalizeDesktopPetPreferences(preferences.pet),
|
||||
projectDisplayNames,
|
||||
}
|
||||
|
||||
await this.writePreferences(nextPreferences)
|
||||
return update
|
||||
})
|
||||
}
|
||||
|
||||
async updatePetPreferences(pet: unknown): Promise<DesktopUiPreferences> {
|
||||
const filePath = this.getPreferencesPath()
|
||||
return this.withWriteLock(filePath, async () => {
|
||||
@@ -424,6 +550,7 @@ export class DesktopUiPreferencesService {
|
||||
...currentPet,
|
||||
...patch,
|
||||
}),
|
||||
projectDisplayNames: normalizeProjectDisplayNames(preferences.projectDisplayNames),
|
||||
}
|
||||
|
||||
await this.writePreferences(nextPreferences)
|
||||
@@ -474,6 +601,7 @@ export class DesktopUiPreferencesService {
|
||||
avatarUpdatedAt: new Date().toISOString(),
|
||||
},
|
||||
pet: normalizeDesktopPetPreferences(preferences.pet),
|
||||
projectDisplayNames: normalizeProjectDisplayNames(preferences.projectDisplayNames),
|
||||
}
|
||||
|
||||
await this.writePreferences(nextPreferences)
|
||||
@@ -500,6 +628,7 @@ export class DesktopUiPreferencesService {
|
||||
avatarUpdatedAt: null,
|
||||
},
|
||||
pet: normalizeDesktopPetPreferences(preferences.pet),
|
||||
projectDisplayNames: normalizeProjectDisplayNames(preferences.projectDisplayNames),
|
||||
}
|
||||
|
||||
await this.writePreferences(nextPreferences)
|
||||
|
||||
Reference in New Issue
Block a user