mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-31 16:33:34 +08:00
feat(desktop): wire LspStatusIndicator into WorkspacePanel (#51)
Replaces the bare 'LSP {state} . N diagnostics' span in the file preview header with the existing LspStatusIndicator component, which was already authored, tested, and shipped on main but had no parent rendering it.
What you now get on the workspace file preview header:
- Spinning loader for starting/idle, green check for ready, red alert with N error count when ready+errors, red retry button when unavailable
- Click the pill to open a diagnostics dropdown listing path:line:col plus truncated message; Enter on a row opens that file in the preview
- Retry button round-trips POST /api/sessions/:id/lsp/restart and re-reads state, since LspManager keeps unavailable workspaces sticky until an explicit restart
Type bridge:
- The server wire shape (WorkspaceLspState: idle/starting/ready/unavailable with an error string) does not carry LspUnavailableReason, but LspStatusIndicator was authored against the legacy four-state shape with reason+errorCount
- New pure adapter desktop/src/lib/lspStateMap.ts maps the two shapes: idle-as-starting, ready threads errorCount derived from severity=error diagnostics, unavailable collapses to init-failed (Retry path)
Tested:
- bunx vitest src/lib/lspStateMap.test.ts (8 cases — all four state transitions plus diagnostic severity counting)
- bunx vitest LspStatusIndicator + workspacePanelStore — 53/53 pass
- bun run lint (tsc --noEmit) clean
Not-tested: live-LSP retry round-trip (covered by lspManager server tests separately)
Confidence: high
Scope-risk: narrow
Co-authored-by: 你的姓名 <you@example.com>
This commit is contained in:
parent
355b7294b9
commit
149d6a9b44
@ -31,6 +31,9 @@ import {
|
||||
import { WorkspaceFileOpenWith } from './WorkspaceFileOpenWith'
|
||||
import { WorkspaceEditor, saveWorkspaceBuffer } from './WorkspaceEditor'
|
||||
import { UnsavedChangesModal } from './UnsavedChangesModal'
|
||||
import { LspStatusIndicator } from './LspStatusIndicator'
|
||||
import { errorCountFromDiagnostics, toLegacyLspState } from '../../lib/lspStateMap'
|
||||
import { sessionsApi } from '../../api/sessions'
|
||||
|
||||
type WorkspacePanelProps = {
|
||||
sessionId: string
|
||||
@ -981,6 +984,7 @@ export function WorkspacePanel({ sessionId, embedded = false }: WorkspacePanelPr
|
||||
const closePreviewTabs = useWorkspacePanelStore((state) => state.closePreviewTabs)
|
||||
const initBuffer = useWorkspacePanelStore((state) => state.initBuffer)
|
||||
const syncLsp = useWorkspacePanelStore((state) => state.syncLsp)
|
||||
const loadLspState = useWorkspacePanelStore((state) => state.loadLspState)
|
||||
const bufferStateByTabId = useWorkspacePanelStore((state) => state.bufferStateByTabId)
|
||||
const closePanel = useWorkspacePanelStore((state) => state.closePanel)
|
||||
const addWorkspaceReference = useWorkspaceChatContextStore((state) => state.addReference)
|
||||
@ -1354,17 +1358,26 @@ export function WorkspacePanel({ sessionId, embedded = false }: WorkspacePanelPr
|
||||
))}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{activePreviewTab.kind === 'file' && (
|
||||
<span
|
||||
data-testid="workspace-lsp-summary"
|
||||
className={`shrink-0 rounded-[5px] border px-1.5 py-0.5 text-[10px] font-medium ${
|
||||
activeLspDiagnostics?.diagnosticsTotal
|
||||
? 'border-[var(--color-error)]/30 text-[var(--color-error)]'
|
||||
: 'border-[var(--color-border)] text-[var(--color-text-tertiary)]'
|
||||
}`}
|
||||
title={lspState?.error ?? lspState?.command ?? undefined}
|
||||
>
|
||||
{t('workspace.lspState', { state: lspState?.state ?? 'idle' })} · {t('workspace.lspDiagnostics', { count: activeLspDiagnostics?.diagnosticsTotal ?? 0 })}
|
||||
</span>
|
||||
<LspStatusIndicator
|
||||
state={toLegacyLspState(
|
||||
lspState,
|
||||
errorCountFromDiagnostics(activeLspDiagnostics?.diagnostics),
|
||||
sessionId,
|
||||
)}
|
||||
diagnostics={activeLspDiagnostics?.diagnostics ?? []}
|
||||
onRetryClick={() => {
|
||||
// Server-side LspManager keeps `unavailable` workspaces
|
||||
// sticky until an explicit restart, so this round-trips
|
||||
// through /lsp/restart and then re-reads state.
|
||||
void sessionsApi
|
||||
.restartWorkspaceLsp(sessionId, { path: activePreviewTab.path })
|
||||
.catch(() => undefined)
|
||||
.then(() => loadLspState(sessionId, activePreviewTab.path))
|
||||
}}
|
||||
onDiagnosticOpen={(diagnostic) => {
|
||||
void openPreview(sessionId, diagnostic.path, 'file')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
99
desktop/src/lib/lspStateMap.test.ts
Normal file
99
desktop/src/lib/lspStateMap.test.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { errorCountFromDiagnostics, toLegacyLspState } from './lspStateMap'
|
||||
import type { LspDiagnostic, WorkspaceLspState } from '../types/lsp'
|
||||
|
||||
describe('toLegacyLspState', () => {
|
||||
it('returns starting when state is undefined (never loaded)', () => {
|
||||
const result = toLegacyLspState(undefined, 0, 'w1')
|
||||
expect(result).toEqual({ state: 'starting', workspaceId: 'w1', errorCount: 0 })
|
||||
})
|
||||
|
||||
it('treats idle as starting so the pill does not flash unavailable', () => {
|
||||
const idle: WorkspaceLspState = { state: 'idle', path: null, serverName: null, command: null }
|
||||
expect(toLegacyLspState(idle, 0, 'w1')).toEqual({
|
||||
state: 'starting',
|
||||
workspaceId: 'w1',
|
||||
errorCount: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('passes through starting', () => {
|
||||
const starting: WorkspaceLspState = { state: 'starting', path: null, serverName: null, command: null }
|
||||
expect(toLegacyLspState(starting, 0, 'w1')).toEqual({
|
||||
state: 'starting',
|
||||
workspaceId: 'w1',
|
||||
errorCount: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('passes through ready and threads errorCount', () => {
|
||||
const ready: WorkspaceLspState = {
|
||||
state: 'ready',
|
||||
path: 'src/app.ts',
|
||||
serverName: 'typescript',
|
||||
command: 'typescript-language-server',
|
||||
}
|
||||
expect(toLegacyLspState(ready, 3, 'w1')).toEqual({
|
||||
state: 'ready',
|
||||
workspaceId: 'w1',
|
||||
errorCount: 3,
|
||||
})
|
||||
})
|
||||
|
||||
it('maps unavailable to init-failed (Retry button) since wire shape lacks reason', () => {
|
||||
const unavailable: WorkspaceLspState = {
|
||||
state: 'unavailable',
|
||||
path: null,
|
||||
serverName: null,
|
||||
command: null,
|
||||
error: 'spawn ENOENT typescript-language-server',
|
||||
}
|
||||
expect(toLegacyLspState(unavailable, 0, 'w1')).toEqual({
|
||||
state: 'unavailable',
|
||||
workspaceId: 'w1',
|
||||
reason: 'init-failed',
|
||||
errorCount: 0,
|
||||
lastStderrTail: 'spawn ENOENT typescript-language-server',
|
||||
})
|
||||
})
|
||||
|
||||
it('omits lastStderrTail when error string is absent', () => {
|
||||
const unavailable: WorkspaceLspState = {
|
||||
state: 'unavailable',
|
||||
path: null,
|
||||
serverName: null,
|
||||
command: null,
|
||||
}
|
||||
const result = toLegacyLspState(unavailable, 0, 'w1')
|
||||
expect(result).toEqual({
|
||||
state: 'unavailable',
|
||||
workspaceId: 'w1',
|
||||
reason: 'init-failed',
|
||||
errorCount: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('errorCountFromDiagnostics', () => {
|
||||
function diag(severity: LspDiagnostic['severity']): LspDiagnostic {
|
||||
return { path: 'a.ts', line: 1, column: 1, severity, message: 'm' }
|
||||
}
|
||||
|
||||
it('returns 0 when undefined or empty', () => {
|
||||
expect(errorCountFromDiagnostics(undefined)).toBe(0)
|
||||
expect(errorCountFromDiagnostics([])).toBe(0)
|
||||
})
|
||||
|
||||
it('counts only error severity, not warning/info/hint', () => {
|
||||
expect(
|
||||
errorCountFromDiagnostics([
|
||||
diag('error'),
|
||||
diag('warning'),
|
||||
diag('error'),
|
||||
diag('info'),
|
||||
diag('hint'),
|
||||
]),
|
||||
).toBe(2)
|
||||
})
|
||||
})
|
||||
51
desktop/src/lib/lspStateMap.ts
Normal file
51
desktop/src/lib/lspStateMap.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import type {
|
||||
LspDiagnostic,
|
||||
LegacyWorkspaceLspState,
|
||||
WorkspaceLspState,
|
||||
} from '../types/lsp'
|
||||
|
||||
/**
|
||||
* Map the server-side {@link WorkspaceLspState} (one shape, four states
|
||||
* including `idle`) onto the legacy four-state shape that
|
||||
* {@link LspStatusIndicator} consumes.
|
||||
*
|
||||
* The indicator was authored against an earlier wire shape that exposed an
|
||||
* `LspUnavailableReason`. The current server response only carries an `error`
|
||||
* string, so unavailable states collapse to `init-failed` (Retry button) and
|
||||
* never trigger the prereq-missing path. That is acceptable because the
|
||||
* desktop's prereq UX lives in `PluginPrerequisitesModal`, reached from the
|
||||
* Plugins page, not the workspace pill.
|
||||
*/
|
||||
export function toLegacyLspState(
|
||||
state: WorkspaceLspState | undefined,
|
||||
errorCount: number,
|
||||
workspaceId: string,
|
||||
): LegacyWorkspaceLspState {
|
||||
// No state yet (loading, or never queried) — present as `starting` so the
|
||||
// pill spins instead of flashing "unavailable".
|
||||
if (!state || state.state === 'idle' || state.state === 'starting') {
|
||||
return { state: 'starting', workspaceId, errorCount: 0 }
|
||||
}
|
||||
if (state.state === 'ready') {
|
||||
return { state: 'ready', workspaceId, errorCount }
|
||||
}
|
||||
return {
|
||||
state: 'unavailable',
|
||||
workspaceId,
|
||||
reason: 'init-failed',
|
||||
errorCount: 0,
|
||||
...(state.error ? { lastStderrTail: state.error } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Count how many diagnostics in a list are errors (not warnings/info/hints). */
|
||||
export function errorCountFromDiagnostics(
|
||||
diagnostics: readonly LspDiagnostic[] | undefined,
|
||||
): number {
|
||||
if (!diagnostics || diagnostics.length === 0) return 0
|
||||
let count = 0
|
||||
for (const d of diagnostics) {
|
||||
if (d.severity === 'error') count += 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user