Skip to content

Commit 6793ebb

Browse files
authored
Warnings, Underlines, & Hover Information for Issues (#222)
* add warnings * missed fix for warnings with no errors display * polish up the UI for warnings/suggestions * hover info for warnings, errors, suggestions * remove prod compiler issue * change infer target on TsServerSuggestionType
1 parent 37b27cb commit 6793ebb

File tree

13 files changed

+261
-18
lines changed

13 files changed

+261
-18
lines changed

packages/api/server/ws.mts

+26
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import {
5656
CellCreatePayloadSchema,
5757
TsConfigUpdatePayloadSchema,
5858
TsConfigUpdatedPayloadSchema,
59+
TsServerCellSuggestionsPayloadSchema,
5960
} from '@srcbook/shared';
6061
import tsservers from '../tsservers.mjs';
6162
import { TsServer } from '../tsserver/tsserver.mjs';
@@ -601,6 +602,30 @@ function createTsServer(session: SessionType) {
601602
});
602603
});
603604

605+
tsserver.onSuggestionDiag(async (event) => {
606+
const eventBody = event.body;
607+
608+
// Get most recent session state
609+
const session = await findSession(sessionId);
610+
611+
if (!eventBody || !session) {
612+
return;
613+
}
614+
615+
const filename = filenameFromPath(eventBody.file);
616+
const cells = session.cells.filter((cell) => cell.type === 'code') as CodeCellType[];
617+
const cell = cells.find((c) => c.filename === filename);
618+
619+
if (!cell) {
620+
return;
621+
}
622+
623+
wss.broadcast(`session:${session.id}`, 'tsserver:cell:suggestions', {
624+
cellId: cell.id,
625+
diagnostics: eventBody.diagnostics.map(normalizeDiagnostic),
626+
});
627+
});
628+
604629
// Open all code cells in tsserver
605630
for (const cell of session.cells) {
606631
if (cell.type === 'code') {
@@ -678,6 +703,7 @@ wss
678703
.outgoing('ai:generated', AiGeneratedCellPayloadSchema)
679704
.outgoing('deps:validate:response', DepsValidateResponsePayloadSchema)
680705
.outgoing('tsserver:cell:diagnostics', TsServerCellDiagnosticsPayloadSchema)
706+
.outgoing('tsserver:cell:suggestions', TsServerCellSuggestionsPayloadSchema)
681707
.outgoing('tsconfig.json:updated', TsConfigUpdatedPayloadSchema);
682708

683709
export default wss;

packages/shared/src/schemas/tsserver.ts

+8
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,11 @@ export const TsServerDiagnosticSchema = z.object({
1212
start: TsServerLocationSchema,
1313
end: TsServerLocationSchema,
1414
});
15+
16+
export const TsServerSuggestionSchema = z.object({
17+
code: z.number(),
18+
category: z.string(),
19+
text: z.string(),
20+
start: TsServerLocationSchema,
21+
end: TsServerLocationSchema,
22+
});

packages/shared/src/schemas/websockets.ts

+5
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,11 @@ export const TsServerCellDiagnosticsPayloadSchema = z.object({
108108
diagnostics: z.array(TsServerDiagnosticSchema),
109109
});
110110

111+
export const TsServerCellSuggestionsPayloadSchema = z.object({
112+
cellId: z.string(),
113+
diagnostics: z.array(TsServerDiagnosticSchema),
114+
});
115+
111116
export const TsConfigUpdatePayloadSchema = z.object({
112117
sessionId: z.string(),
113118
source: z.string(),

packages/shared/src/types/tsserver.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import z from 'zod';
22

3-
import { TsServerLocationSchema, TsServerDiagnosticSchema } from '../schemas/tsserver.js';
3+
import {
4+
TsServerLocationSchema,
5+
TsServerDiagnosticSchema,
6+
TsServerSuggestionSchema,
7+
} from '../schemas/tsserver.js';
48

59
export type TsServerLocationType = z.infer<typeof TsServerLocationSchema>;
610
export type TsServerDiagnosticType = z.infer<typeof TsServerDiagnosticSchema>;
11+
export type TsServerSuggestionType = z.infer<typeof TsServerSuggestionSchema>;

packages/shared/src/types/websockets.ts

+4
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
TsConfigUpdatePayloadSchema,
2222
TsConfigUpdatedPayloadSchema,
2323
AiFixDiagnosticsPayloadSchema,
24+
TsServerCellSuggestionsPayloadSchema,
2425
} from '../schemas/websockets.js';
2526

2627
export type CellExecPayloadType = z.infer<typeof CellExecPayloadSchema>;
@@ -46,6 +47,9 @@ export type TsServerStopPayloadType = z.infer<typeof TsServerStopPayloadSchema>;
4647
export type TsServerCellDiagnosticsPayloadType = z.infer<
4748
typeof TsServerCellDiagnosticsPayloadSchema
4849
>;
50+
export type TsServerCellSuggestionsPayloadType = z.infer<
51+
typeof TsServerCellSuggestionsPayloadSchema
52+
>;
4953

5054
export type TsConfigUpdatePayloadType = z.infer<typeof TsConfigUpdatePayloadSchema>;
5155
export type TsConfigUpdatedPayloadType = z.infer<typeof TsConfigUpdatedPayloadSchema>;

packages/web/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"@codemirror/lang-javascript": "^6.2.2",
1717
"@codemirror/lang-json": "^6.0.1",
1818
"@codemirror/lang-markdown": "^6.2.5",
19+
"@codemirror/lint": "^6.8.1",
1920
"@codemirror/merge": "^6.6.5",
2021
"@codemirror/state": "^6.4.1",
2122
"@lezer/highlight": "^1.2.0",

packages/web/src/clients/websocket/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
TsConfigUpdatePayloadSchema,
2020
TsConfigUpdatedPayloadSchema,
2121
AiFixDiagnosticsPayloadSchema,
22+
TsServerCellSuggestionsPayloadSchema,
2223
} from '@srcbook/shared';
2324
import Channel from '@/clients/websocket/channel';
2425
import WebSocketClient from '@/clients/websocket/client';
@@ -34,6 +35,7 @@ const IncomingSessionEvents = {
3435
'cell:updated': CellUpdatedPayloadSchema,
3536
'deps:validate:response': DepsValidateResponsePayloadSchema,
3637
'tsserver:cell:diagnostics': TsServerCellDiagnosticsPayloadSchema,
38+
'tsserver:cell:suggestions': TsServerCellSuggestionsPayloadSchema,
3739
'ai:generated': AiGeneratedCellPayloadSchema,
3840
'tsconfig.json:updated': TsConfigUpdatedPayloadSchema,
3941
};

packages/web/src/components/cell-output.tsx

+73-5
Original file line numberDiff line numberDiff line change
@@ -27,19 +27,24 @@ export function CellOutput({
2727
fullscreen,
2828
setFullscreen,
2929
}: PropsType) {
30-
const { getOutput, clearOutput, getTsServerDiagnostics } = useCells();
30+
const { getOutput, clearOutput, getTsServerDiagnostics, getTsServerSuggestions } = useCells();
3131

32-
const [activeTab, setActiveTab] = useState<'stdout' | 'stderr' | 'problems'>('stdout');
32+
const [activeTab, setActiveTab] = useState<'stdout' | 'stderr' | 'problems' | 'warnings'>(
33+
'stdout',
34+
);
3335

3436
const stdout = getOutput(cell.id, 'stdout') as StdoutOutputType[];
3537
const stderr = getOutput(cell.id, 'stderr') as StderrOutputType[];
3638
const diagnostics = getTsServerDiagnostics(cell.id);
39+
const suggestions = getTsServerSuggestions(cell.id);
3740

3841
return (
3942
<div className={cn('font-mono text-sm', fullscreen && !show && 'border-b')}>
4043
<Tabs
4144
value={activeTab}
42-
onValueChange={(value) => setActiveTab(value as 'stdout' | 'stderr' | 'problems')}
45+
onValueChange={(value) =>
46+
setActiveTab(value as 'stdout' | 'stderr' | 'problems' | 'warnings')
47+
}
4348
defaultValue="stdout"
4449
>
4550
<div
@@ -94,6 +99,24 @@ export function CellOutput({
9499
)}
95100
</TabsTrigger>
96101
)}
102+
{cell.type === 'code' && cell.language === 'typescript' && (
103+
<TabsTrigger
104+
onClick={() => setShow(true)}
105+
value="warnings"
106+
className={cn(
107+
!show &&
108+
'border-transparent data-[state=active]:border-transparent data-[state=active]:text-tertiary-foreground mb-0',
109+
)}
110+
>
111+
{suggestions.length > 0 ? (
112+
<>
113+
warnings <span className="text-sb-yellow-50">({suggestions.length})</span>
114+
</>
115+
) : (
116+
'warnings'
117+
)}
118+
</TabsTrigger>
119+
)}
97120
</TabsList>
98121
<div className="flex items-center gap-6">
99122
<button
@@ -106,8 +129,13 @@ export function CellOutput({
106129
</button>
107130
<button
108131
className="hover:text-secondary-hover disabled:pointer-events-none disabled:opacity-50"
109-
disabled={activeTab === 'problems'}
110-
onClick={() => clearOutput(cell.id, activeTab === 'problems' ? undefined : activeTab)}
132+
disabled={activeTab === 'problems' || activeTab === 'warnings'}
133+
onClick={() =>
134+
clearOutput(
135+
cell.id,
136+
activeTab === 'problems' || activeTab === 'warnings' ? undefined : activeTab,
137+
)
138+
}
111139
>
112140
<Ban size={16} />
113141
</button>
@@ -138,6 +166,15 @@ export function CellOutput({
138166
/>
139167
</TabsContent>
140168
)}
169+
{cell.type === 'code' && cell.language === 'typescript' && (
170+
<TabsContent value="warnings" className="mt-0">
171+
<TsServerSuggestions
172+
suggestions={suggestions}
173+
fixSuggestions={fixDiagnostics} // fixDiagnostics works for both diagnostics and suggestions
174+
cellMode={cellMode}
175+
/>
176+
</TabsContent>
177+
)}
141178
</div>
142179
)}
143180
</Tabs>
@@ -203,3 +240,34 @@ function TsServerDiagnostics({
203240
</div>
204241
);
205242
}
243+
244+
function TsServerSuggestions({
245+
suggestions,
246+
fixSuggestions,
247+
cellMode,
248+
}: {
249+
suggestions: TsServerDiagnosticType[];
250+
fixSuggestions: (suggestions: string) => void;
251+
cellMode: 'off' | 'generating' | 'reviewing' | 'prompting' | 'fixing';
252+
}) {
253+
const { aiEnabled } = useSettings();
254+
const formattedSuggestions = suggestions.map(formatDiagnostic).join('\n');
255+
return suggestions.length === 0 ? (
256+
<div className="italic text-center text-muted-foreground">No warnings or suggestions</div>
257+
) : (
258+
<div className="flex flex-col w-full">
259+
<p>{formattedSuggestions}</p>
260+
{aiEnabled && cellMode !== 'fixing' && (
261+
<Button
262+
variant="ai"
263+
className="self-start flex items-center gap-2 px-2.5 py-2 font-sans h-7 mt-3"
264+
onClick={() => fixSuggestions(formattedSuggestions)}
265+
disabled={cellMode === 'generating'}
266+
>
267+
<Sparkles size={16} />
268+
<p>Fix with AI</p>
269+
</Button>
270+
)}
271+
</div>
272+
);
273+
}

packages/web/src/components/cells/code.tsx

+87-1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
CodeCellUpdateAttrsType,
2727
CellErrorPayloadType,
2828
AiGeneratedCellPayloadType,
29+
TsServerDiagnosticType,
2930
} from '@srcbook/shared';
3031
import { useSettings } from '@/components/use-settings';
3132
import { cn } from '@/lib/utils';
@@ -41,6 +42,7 @@ import { useDebouncedCallback } from 'use-debounce';
4142
import { EditorView } from 'codemirror';
4243
import { EditorState } from '@codemirror/state';
4344
import { unifiedMergeView } from '@codemirror/merge';
45+
import { type Diagnostic, linter } from '@codemirror/lint';
4446

4547
const DEBOUNCE_DELAY = 500;
4648
type CellModeType = 'off' | 'generating' | 'reviewing' | 'prompting' | 'fixing';
@@ -543,6 +545,84 @@ function Header(props: {
543545
);
544546
}
545547

548+
function tsCategoryToSeverity(
549+
diagnostic: Pick<TsServerDiagnosticType, 'category' | 'code'>,
550+
): Diagnostic['severity'] {
551+
if (diagnostic.code === 7027) {
552+
return 'warning';
553+
}
554+
// force resolve types with fallback
555+
switch (diagnostic.category) {
556+
case 'error':
557+
return 'error';
558+
case 'warning':
559+
return 'warning';
560+
case 'suggestion':
561+
return 'warning';
562+
case 'info':
563+
return 'info';
564+
default:
565+
return 'info';
566+
}
567+
}
568+
569+
function isDiagnosticWithLocation(
570+
diagnostic: TsServerDiagnosticType,
571+
): diagnostic is TsServerDiagnosticType {
572+
return !!(typeof diagnostic.start.line === 'number' && typeof diagnostic.end.line === 'number');
573+
}
574+
575+
function tsDiagnosticMessage(diagnostic: TsServerDiagnosticType): string {
576+
if (typeof diagnostic.text === 'string') {
577+
return diagnostic.text;
578+
}
579+
return JSON.stringify(diagnostic); // Fallback
580+
}
581+
582+
function convertTSDiagnosticToCM(diagnostic: TsServerDiagnosticType, code: string): Diagnostic {
583+
const message = tsDiagnosticMessage(diagnostic);
584+
585+
// parse conversion TS server is {line, offset} to CodeMirror {from, to} in absolute chars
586+
return {
587+
from: Math.min(
588+
code.length - 1,
589+
code
590+
.split('\n')
591+
.slice(0, diagnostic.start.line - 1)
592+
.join('\n').length + diagnostic.start.offset,
593+
),
594+
to: Math.min(
595+
code.length - 1,
596+
code
597+
.split('\n')
598+
.slice(0, diagnostic.end.line - 1)
599+
.join('\n').length + diagnostic.end.offset,
600+
),
601+
message: message,
602+
severity: tsCategoryToSeverity(diagnostic),
603+
};
604+
}
605+
606+
function tsLinter(
607+
cell: CodeCellType,
608+
getTsServerDiagnostics: (id: string) => TsServerDiagnosticType[],
609+
getTsServerSuggestions: (id: string) => TsServerDiagnosticType[],
610+
) {
611+
const semanticDiagnostics = getTsServerDiagnostics(cell.id);
612+
const syntaticDiagnostics = getTsServerSuggestions(cell.id);
613+
const diagnostics = [...syntaticDiagnostics, ...semanticDiagnostics].filter(
614+
isDiagnosticWithLocation,
615+
);
616+
617+
const cm_diagnostics = diagnostics.map((diagnostic) => {
618+
return convertTSDiagnosticToCM(diagnostic, cell.source);
619+
});
620+
621+
return linter(async (): Promise<readonly Diagnostic[]> => {
622+
return cm_diagnostics;
623+
});
624+
}
625+
546626
function CodeEditor({
547627
cell,
548628
runCell,
@@ -555,7 +635,11 @@ function CodeEditor({
555635
readOnly: boolean;
556636
}) {
557637
const { codeTheme } = useTheme();
558-
const { updateCell: updateCellOnClient } = useCells();
638+
const {
639+
updateCell: updateCellOnClient,
640+
getTsServerDiagnostics,
641+
getTsServerSuggestions,
642+
} = useCells();
559643

560644
const updateCellOnServerDebounced = useDebouncedCallback(updateCellOnServer, DEBOUNCE_DELAY);
561645

@@ -566,6 +650,8 @@ function CodeEditor({
566650

567651
let extensions = [
568652
javascript({ typescript: true }),
653+
// wordHoverExtension,
654+
tsLinter(cell, getTsServerDiagnostics, getTsServerSuggestions),
569655
Prec.highest(keymap.of([{ key: 'Mod-Enter', run: evaluateModEnter }])),
570656
];
571657
if (readOnly) {

0 commit comments

Comments
 (0)