mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-08-02 16:51:13 +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 () => {
|
it('lazy loads the root tree, expands directories, and opens file previews from the all-files view', async () => {
|
||||||
const statusRequest = deferred<{
|
const statusRequest = deferred<{
|
||||||
state: 'ok'
|
state: 'ok'
|
||||||
@ -761,6 +821,43 @@ describe('WorkspacePanel', () => {
|
|||||||
expect(view.getByRole('button', { name: '已更改文件' })).toBeTruthy()
|
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 () => {
|
it('shows explicit empty and error states in the changed view', async () => {
|
||||||
await setWorkspaceState((state) => ({
|
await setWorkspaceState((state) => ({
|
||||||
...state,
|
...state,
|
||||||
|
|||||||
@ -242,9 +242,9 @@ function ToolbarIconButton({
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
onClick={onClick}
|
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>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -937,7 +937,7 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
|||||||
<div
|
<div
|
||||||
className={`${hasPreviewTabs ? 'basis-[32%] min-w-[220px] max-w-[320px]' : 'w-full'} flex h-full shrink-0 flex-col bg-[var(--color-surface)]`}
|
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">
|
<div className="relative min-w-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -945,17 +945,17 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
|||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={isViewMenuOpen}
|
aria-expanded={isViewMenuOpen}
|
||||||
onClick={() => setIsViewMenuOpen((open) => !open)}
|
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">
|
<span className="truncate">
|
||||||
{activeView === 'changed' ? t('workspace.changedFiles') : t('workspace.allFiles')}
|
{activeView === 'changed' ? t('workspace.changedFiles') : t('workspace.allFiles')}
|
||||||
</span>
|
</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>
|
</button>
|
||||||
{isViewMenuOpen && (
|
{isViewMenuOpen && (
|
||||||
<div
|
<div
|
||||||
role="menu"
|
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) => {
|
{(['changed', 'all'] as const).map((view) => {
|
||||||
const selected = activeView === view
|
const selected = activeView === view
|
||||||
@ -965,7 +965,7 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
onClick={() => handleSetActiveView(view)}
|
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)]'
|
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')}
|
{view === 'changed' ? t('workspace.changedFiles') : t('workspace.allFiles')}
|
||||||
</span>
|
</span>
|
||||||
{selected && (
|
{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>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -120,6 +120,65 @@ describe('workspacePanelStore', () => {
|
|||||||
expect(useWorkspacePanelStore.getState().errors.statusBySession['session-1']).toBeNull()
|
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 () => {
|
it('captures workspace status request errors', async () => {
|
||||||
mocks.getWorkspaceStatusMock.mockRejectedValue(new Error('status failed'))
|
mocks.getWorkspaceStatusMock.mockRejectedValue(new Error('status failed'))
|
||||||
|
|
||||||
|
|||||||
@ -38,6 +38,7 @@ export type WorkspacePreviewTab = {
|
|||||||
export type WorkspacePanelSessionState = {
|
export type WorkspacePanelSessionState = {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
activeView: WorkspacePanelView
|
activeView: WorkspacePanelView
|
||||||
|
hasUserSelectedView?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkspacePanelLoadingState = {
|
type WorkspacePanelLoadingState = {
|
||||||
@ -246,6 +247,7 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
|
|||||||
[sessionId]: {
|
[sessionId]: {
|
||||||
...getSessionPanelState(state.panelBySession, sessionId),
|
...getSessionPanelState(state.panelBySession, sessionId),
|
||||||
activeView: view,
|
activeView: view,
|
||||||
|
hasUserSelectedView: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
@ -274,7 +276,22 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
|
|||||||
const result = await sessionsApi.getWorkspaceStatus(sessionId)
|
const result = await sessionsApi.getWorkspaceStatus(sessionId)
|
||||||
if (!isLatestRequest(statusRequestIds, sessionId, requestId)) return
|
if (!isLatestRequest(statusRequestIds, sessionId, requestId)) return
|
||||||
|
|
||||||
set((state) => ({
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
statusBySession: {
|
statusBySession: {
|
||||||
...state.statusBySession,
|
...state.statusBySession,
|
||||||
[sessionId]: result,
|
[sessionId]: result,
|
||||||
@ -293,7 +310,8 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
|
|||||||
[sessionId]: result.error ?? null,
|
[sessionId]: result.error ?? null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}))
|
}
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isLatestRequest(statusRequestIds, sessionId, requestId)) return
|
if (!isLatestRequest(statusRequestIds, sessionId, requestId)) return
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user