mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
fix: default empty workspace changes to file tree
The workspace panel now treats an empty changed-files status as a browsing case, so opening the panel shows the project tree instead of an empty changed-files view. The panel header controls were also reduced to keep the right workspace rail visually proportional in compact layouts. Constraint: Users still need to manually inspect the empty changed-files view when they explicitly choose it. Rejected: Always render the empty changed-files state | it makes the opened workspace feel broken when no files changed. Confidence: high Scope-risk: narrow Directive: Do not auto-switch the workspace view after a user has explicitly selected a workspace view. Tested: cd desktop && bun run test -- src/components/workspace/WorkspacePanel.test.tsx src/stores/workspacePanelStore.test.ts Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Not-tested: Browser E2E visual screenshot pass was not rerun for this small header sizing change.
This commit is contained in:
parent
6d56f3c21d
commit
ba8c6b8828
@ -273,6 +273,66 @@ describe('WorkspacePanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('opens to all files when the current turn has no changed files', async () => {
|
||||
const statusRequest = deferred<{
|
||||
state: 'ok'
|
||||
workDir: string
|
||||
repoName: string
|
||||
branch: string
|
||||
isGitRepo: true
|
||||
changedFiles: []
|
||||
}>()
|
||||
const rootTreeRequest = deferred<{
|
||||
state: 'ok'
|
||||
path: ''
|
||||
entries: Array<{ name: string; path: string; isDirectory: boolean }>
|
||||
}>()
|
||||
|
||||
getMocks().getWorkspaceStatusMock.mockReturnValue(statusRequest.promise)
|
||||
getMocks().getWorkspaceTreeMock.mockReturnValue(rootTreeRequest.promise)
|
||||
|
||||
await act(() => {
|
||||
useWorkspacePanelStore.getState().openPanel('session-empty-tree')
|
||||
})
|
||||
|
||||
const view = await renderPanel('session-empty-tree')
|
||||
expect(view.getByRole('button', { name: 'Changed files' })).toBeTruthy()
|
||||
|
||||
await act(async () => {
|
||||
statusRequest.resolve({
|
||||
state: 'ok',
|
||||
workDir: '/repo',
|
||||
repoName: 'repo',
|
||||
branch: 'main',
|
||||
isGitRepo: true,
|
||||
changedFiles: [],
|
||||
})
|
||||
await statusRequest.promise
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useWorkspacePanelStore.getState().getActiveView('session-empty-tree')).toBe('all')
|
||||
expect(getMocks().getWorkspaceTreeMock).toHaveBeenCalledWith('session-empty-tree', '')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
rootTreeRequest.resolve({
|
||||
state: 'ok',
|
||||
path: '',
|
||||
entries: [
|
||||
{ name: 'src', path: 'src', isDirectory: true },
|
||||
{ name: 'README.md', path: 'README.md', isDirectory: false },
|
||||
],
|
||||
})
|
||||
await rootTreeRequest.promise
|
||||
})
|
||||
|
||||
expect(view.getByRole('button', { name: 'All files' })).toBeTruthy()
|
||||
expect(await view.findByText('src')).toBeTruthy()
|
||||
expect(await view.findByText('README.md')).toBeTruthy()
|
||||
expect(view.queryByText('No changes')).toBeNull()
|
||||
})
|
||||
|
||||
it('lazy loads the root tree, expands directories, and opens file previews from the all-files view', async () => {
|
||||
const statusRequest = deferred<{
|
||||
state: 'ok'
|
||||
@ -761,6 +821,43 @@ describe('WorkspacePanel', () => {
|
||||
expect(view.getByRole('button', { name: '已更改文件' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the workspace header controls compact', async () => {
|
||||
await setWorkspaceState((state) => ({
|
||||
...state,
|
||||
panelBySession: {
|
||||
...state.panelBySession,
|
||||
'session-compact-header': {
|
||||
isOpen: true,
|
||||
activeView: 'changed',
|
||||
},
|
||||
},
|
||||
statusBySession: {
|
||||
...state.statusBySession,
|
||||
'session-compact-header': {
|
||||
state: 'ok',
|
||||
workDir: '/repo',
|
||||
repoName: 'repo',
|
||||
branch: 'main',
|
||||
isGitRepo: true,
|
||||
changedFiles: [],
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const view = await renderPanel('session-compact-header')
|
||||
const viewMenuButton = view.getByRole('button', { name: 'Changed files' })
|
||||
const refreshButton = view.getByRole('button', { name: 'Refresh workspace' })
|
||||
const closeButton = view.getByRole('button', { name: 'Close workspace panel' })
|
||||
|
||||
expect(viewMenuButton.className).toContain('text-[14px]')
|
||||
expect(viewMenuButton.className).not.toContain('text-[18px]')
|
||||
expect(viewMenuButton.querySelector('.material-symbols-outlined')?.className).toContain('text-[15px]')
|
||||
expect(refreshButton.className).toContain('h-7 w-7')
|
||||
expect(closeButton.className).toContain('h-7 w-7')
|
||||
expect(refreshButton.querySelector('.material-symbols-outlined')?.className).toContain('text-[16px]')
|
||||
expect(closeButton.querySelector('.material-symbols-outlined')?.className).toContain('text-[16px]')
|
||||
})
|
||||
|
||||
it('shows explicit empty and error states in the changed view', async () => {
|
||||
await setWorkspaceState((state) => ({
|
||||
...state,
|
||||
|
||||
@ -242,9 +242,9 @@ function ToolbarIconButton({
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-[9px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-[7px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">{icon}</span>
|
||||
<span className="material-symbols-outlined text-[16px]">{icon}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@ -937,7 +937,7 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
<div
|
||||
className={`${hasPreviewTabs ? 'basis-[32%] min-w-[220px] max-w-[320px]' : 'w-full'} flex h-full shrink-0 flex-col bg-[var(--color-surface)]`}
|
||||
>
|
||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-[var(--color-border)] px-3">
|
||||
<div className="flex h-10 shrink-0 items-center gap-1.5 border-b border-[var(--color-border)] px-2.5">
|
||||
<div className="relative min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
@ -945,17 +945,17 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isViewMenuOpen}
|
||||
onClick={() => setIsViewMenuOpen((open) => !open)}
|
||||
className="inline-flex min-w-0 max-w-full items-center gap-1 rounded-[8px] px-2.5 py-1.5 text-[18px] font-semibold leading-none text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
|
||||
className="inline-flex min-w-0 max-w-full items-center gap-1 rounded-[7px] px-2 py-1 text-[14px] font-semibold leading-5 text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
|
||||
>
|
||||
<span className="truncate">
|
||||
{activeView === 'changed' ? t('workspace.changedFiles') : t('workspace.allFiles')}
|
||||
</span>
|
||||
<span className="material-symbols-outlined shrink-0 text-[18px] font-normal text-[var(--color-text-tertiary)]">expand_more</span>
|
||||
<span className="material-symbols-outlined shrink-0 text-[15px] font-normal text-[var(--color-text-tertiary)]">expand_more</span>
|
||||
</button>
|
||||
{isViewMenuOpen && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute left-0 top-[calc(100%+6px)] z-30 min-w-[132px] overflow-hidden rounded-[10px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1 shadow-[var(--shadow-dropdown)]"
|
||||
className="absolute left-0 top-[calc(100%+4px)] z-30 min-w-[124px] overflow-hidden rounded-[9px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1 shadow-[var(--shadow-dropdown)]"
|
||||
>
|
||||
{(['changed', 'all'] as const).map((view) => {
|
||||
const selected = activeView === view
|
||||
@ -965,7 +965,7 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => handleSetActiveView(view)}
|
||||
className={`flex h-8 w-full items-center gap-2 px-3 text-left text-[13px] transition-colors ${
|
||||
className={`flex h-7 w-full items-center gap-2 px-2.5 text-left text-[12px] transition-colors ${
|
||||
selected ? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
@ -973,7 +973,7 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
{view === 'changed' ? t('workspace.changedFiles') : t('workspace.allFiles')}
|
||||
</span>
|
||||
{selected && (
|
||||
<span className="material-symbols-outlined text-[15px] text-[var(--color-brand)]">check</span>
|
||||
<span className="material-symbols-outlined text-[14px] text-[var(--color-brand)]">check</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
|
||||
@ -120,6 +120,65 @@ describe('workspacePanelStore', () => {
|
||||
expect(useWorkspacePanelStore.getState().errors.statusBySession['session-1']).toBeNull()
|
||||
})
|
||||
|
||||
it('defaults an empty changed-files status to the all-files view', async () => {
|
||||
mocks.getWorkspaceStatusMock.mockResolvedValue({
|
||||
state: 'ok',
|
||||
workDir: '/repo',
|
||||
repoName: 'repo',
|
||||
branch: 'main',
|
||||
isGitRepo: true,
|
||||
changedFiles: [],
|
||||
})
|
||||
|
||||
useWorkspacePanelStore.getState().openPanel('session-empty-changes')
|
||||
expect(useWorkspacePanelStore.getState().getActiveView('session-empty-changes')).toBe('changed')
|
||||
|
||||
await useWorkspacePanelStore.getState().loadStatus('session-empty-changes')
|
||||
|
||||
expect(useWorkspacePanelStore.getState().statusBySession['session-empty-changes']?.changedFiles).toEqual([])
|
||||
expect(useWorkspacePanelStore.getState().getActiveView('session-empty-changes')).toBe('all')
|
||||
})
|
||||
|
||||
it('keeps the changed-files view when status contains changes', async () => {
|
||||
mocks.getWorkspaceStatusMock.mockResolvedValue({
|
||||
state: 'ok',
|
||||
workDir: '/repo',
|
||||
repoName: 'repo',
|
||||
branch: 'main',
|
||||
isGitRepo: true,
|
||||
changedFiles: [
|
||||
{
|
||||
path: 'src/app.ts',
|
||||
status: 'modified',
|
||||
additions: 2,
|
||||
deletions: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
useWorkspacePanelStore.getState().openPanel('session-has-changes')
|
||||
await useWorkspacePanelStore.getState().loadStatus('session-has-changes')
|
||||
|
||||
expect(useWorkspacePanelStore.getState().getActiveView('session-has-changes')).toBe('changed')
|
||||
})
|
||||
|
||||
it('does not override an explicit changed-files selection when status is empty', async () => {
|
||||
mocks.getWorkspaceStatusMock.mockResolvedValue({
|
||||
state: 'ok',
|
||||
workDir: '/repo',
|
||||
repoName: 'repo',
|
||||
branch: 'main',
|
||||
isGitRepo: true,
|
||||
changedFiles: [],
|
||||
})
|
||||
|
||||
useWorkspacePanelStore.getState().openPanel('session-explicit-changed')
|
||||
useWorkspacePanelStore.getState().setActiveView('session-explicit-changed', 'changed')
|
||||
await useWorkspacePanelStore.getState().loadStatus('session-explicit-changed')
|
||||
|
||||
expect(useWorkspacePanelStore.getState().getActiveView('session-explicit-changed')).toBe('changed')
|
||||
})
|
||||
|
||||
it('captures workspace status request errors', async () => {
|
||||
mocks.getWorkspaceStatusMock.mockRejectedValue(new Error('status failed'))
|
||||
|
||||
|
||||
@ -38,6 +38,7 @@ export type WorkspacePreviewTab = {
|
||||
export type WorkspacePanelSessionState = {
|
||||
isOpen: boolean
|
||||
activeView: WorkspacePanelView
|
||||
hasUserSelectedView?: boolean
|
||||
}
|
||||
|
||||
type WorkspacePanelLoadingState = {
|
||||
@ -246,6 +247,7 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
|
||||
[sessionId]: {
|
||||
...getSessionPanelState(state.panelBySession, sessionId),
|
||||
activeView: view,
|
||||
hasUserSelectedView: true,
|
||||
},
|
||||
},
|
||||
})),
|
||||
@ -274,26 +276,42 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
|
||||
const result = await sessionsApi.getWorkspaceStatus(sessionId)
|
||||
if (!isLatestRequest(statusRequestIds, sessionId, requestId)) return
|
||||
|
||||
set((state) => ({
|
||||
statusBySession: {
|
||||
...state.statusBySession,
|
||||
[sessionId]: result,
|
||||
},
|
||||
loading: {
|
||||
...state.loading,
|
||||
statusBySession: {
|
||||
...state.loading.statusBySession,
|
||||
[sessionId]: false,
|
||||
set((state) => {
|
||||
const panel = getSessionPanelState(state.panelBySession, sessionId)
|
||||
const shouldDefaultToAllFiles =
|
||||
!panel.hasUserSelectedView
|
||||
&& panel.activeView === 'changed'
|
||||
&& result.state === 'ok'
|
||||
&& result.changedFiles.length === 0
|
||||
|
||||
return {
|
||||
panelBySession: {
|
||||
...state.panelBySession,
|
||||
[sessionId]: {
|
||||
...panel,
|
||||
activeView: shouldDefaultToAllFiles ? 'all' : panel.activeView,
|
||||
},
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
...state.errors,
|
||||
statusBySession: {
|
||||
...state.errors.statusBySession,
|
||||
[sessionId]: result.error ?? null,
|
||||
...state.statusBySession,
|
||||
[sessionId]: result,
|
||||
},
|
||||
},
|
||||
}))
|
||||
loading: {
|
||||
...state.loading,
|
||||
statusBySession: {
|
||||
...state.loading.statusBySession,
|
||||
[sessionId]: false,
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
...state.errors,
|
||||
statusBySession: {
|
||||
...state.errors.statusBySession,
|
||||
[sessionId]: result.error ?? null,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
if (!isLatestRequest(statusRequestIds, sessionId, requestId)) return
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user