Skip to content

Commit

Permalink
Warnings, Underlines, & Hover Information for Issues (#222)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
versecafe authored Aug 27, 2024
1 parent 37b27cb commit 6793ebb
Show file tree
Hide file tree
Showing 13 changed files with 261 additions and 18 deletions.
26 changes: 26 additions & 0 deletions packages/api/server/ws.mts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
CellCreatePayloadSchema,
TsConfigUpdatePayloadSchema,
TsConfigUpdatedPayloadSchema,
TsServerCellSuggestionsPayloadSchema,
} from '@srcbook/shared';
import tsservers from '../tsservers.mjs';
import { TsServer } from '../tsserver/tsserver.mjs';
Expand Down Expand Up @@ -601,6 +602,30 @@ function createTsServer(session: SessionType) {
});
});

tsserver.onSuggestionDiag(async (event) => {
const eventBody = event.body;

// Get most recent session state
const session = await findSession(sessionId);

if (!eventBody || !session) {
return;
}

const filename = filenameFromPath(eventBody.file);
const cells = session.cells.filter((cell) => cell.type === 'code') as CodeCellType[];
const cell = cells.find((c) => c.filename === filename);

if (!cell) {
return;
}

wss.broadcast(`session:${session.id}`, 'tsserver:cell:suggestions', {
cellId: cell.id,
diagnostics: eventBody.diagnostics.map(normalizeDiagnostic),
});
});

// Open all code cells in tsserver
for (const cell of session.cells) {
if (cell.type === 'code') {
Expand Down Expand Up @@ -678,6 +703,7 @@ wss
.outgoing('ai:generated', AiGeneratedCellPayloadSchema)
.outgoing('deps:validate:response', DepsValidateResponsePayloadSchema)
.outgoing('tsserver:cell:diagnostics', TsServerCellDiagnosticsPayloadSchema)
.outgoing('tsserver:cell:suggestions', TsServerCellSuggestionsPayloadSchema)
.outgoing('tsconfig.json:updated', TsConfigUpdatedPayloadSchema);

export default wss;
8 changes: 8 additions & 0 deletions packages/shared/src/schemas/tsserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,11 @@ export const TsServerDiagnosticSchema = z.object({
start: TsServerLocationSchema,
end: TsServerLocationSchema,
});

export const TsServerSuggestionSchema = z.object({
code: z.number(),
category: z.string(),
text: z.string(),
start: TsServerLocationSchema,
end: TsServerLocationSchema,
});
5 changes: 5 additions & 0 deletions packages/shared/src/schemas/websockets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,11 @@ export const TsServerCellDiagnosticsPayloadSchema = z.object({
diagnostics: z.array(TsServerDiagnosticSchema),
});

export const TsServerCellSuggestionsPayloadSchema = z.object({
cellId: z.string(),
diagnostics: z.array(TsServerDiagnosticSchema),
});

export const TsConfigUpdatePayloadSchema = z.object({
sessionId: z.string(),
source: z.string(),
Expand Down
7 changes: 6 additions & 1 deletion packages/shared/src/types/tsserver.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import z from 'zod';

import { TsServerLocationSchema, TsServerDiagnosticSchema } from '../schemas/tsserver.js';
import {
TsServerLocationSchema,
TsServerDiagnosticSchema,
TsServerSuggestionSchema,
} from '../schemas/tsserver.js';

export type TsServerLocationType = z.infer<typeof TsServerLocationSchema>;
export type TsServerDiagnosticType = z.infer<typeof TsServerDiagnosticSchema>;
export type TsServerSuggestionType = z.infer<typeof TsServerSuggestionSchema>;
4 changes: 4 additions & 0 deletions packages/shared/src/types/websockets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
TsConfigUpdatePayloadSchema,
TsConfigUpdatedPayloadSchema,
AiFixDiagnosticsPayloadSchema,
TsServerCellSuggestionsPayloadSchema,
} from '../schemas/websockets.js';

export type CellExecPayloadType = z.infer<typeof CellExecPayloadSchema>;
Expand All @@ -46,6 +47,9 @@ export type TsServerStopPayloadType = z.infer<typeof TsServerStopPayloadSchema>;
export type TsServerCellDiagnosticsPayloadType = z.infer<
typeof TsServerCellDiagnosticsPayloadSchema
>;
export type TsServerCellSuggestionsPayloadType = z.infer<
typeof TsServerCellSuggestionsPayloadSchema
>;

export type TsConfigUpdatePayloadType = z.infer<typeof TsConfigUpdatePayloadSchema>;
export type TsConfigUpdatedPayloadType = z.infer<typeof TsConfigUpdatedPayloadSchema>;
1 change: 1 addition & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"@codemirror/lang-javascript": "^6.2.2",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-markdown": "^6.2.5",
"@codemirror/lint": "^6.8.1",
"@codemirror/merge": "^6.6.5",
"@codemirror/state": "^6.4.1",
"@lezer/highlight": "^1.2.0",
Expand Down
2 changes: 2 additions & 0 deletions packages/web/src/clients/websocket/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
TsConfigUpdatePayloadSchema,
TsConfigUpdatedPayloadSchema,
AiFixDiagnosticsPayloadSchema,
TsServerCellSuggestionsPayloadSchema,
} from '@srcbook/shared';
import Channel from '@/clients/websocket/channel';
import WebSocketClient from '@/clients/websocket/client';
Expand All @@ -34,6 +35,7 @@ const IncomingSessionEvents = {
'cell:updated': CellUpdatedPayloadSchema,
'deps:validate:response': DepsValidateResponsePayloadSchema,
'tsserver:cell:diagnostics': TsServerCellDiagnosticsPayloadSchema,
'tsserver:cell:suggestions': TsServerCellSuggestionsPayloadSchema,
'ai:generated': AiGeneratedCellPayloadSchema,
'tsconfig.json:updated': TsConfigUpdatedPayloadSchema,
};
Expand Down
78 changes: 73 additions & 5 deletions packages/web/src/components/cell-output.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,24 @@ export function CellOutput({
fullscreen,
setFullscreen,
}: PropsType) {
const { getOutput, clearOutput, getTsServerDiagnostics } = useCells();
const { getOutput, clearOutput, getTsServerDiagnostics, getTsServerSuggestions } = useCells();

const [activeTab, setActiveTab] = useState<'stdout' | 'stderr' | 'problems'>('stdout');
const [activeTab, setActiveTab] = useState<'stdout' | 'stderr' | 'problems' | 'warnings'>(
'stdout',
);

const stdout = getOutput(cell.id, 'stdout') as StdoutOutputType[];
const stderr = getOutput(cell.id, 'stderr') as StderrOutputType[];
const diagnostics = getTsServerDiagnostics(cell.id);
const suggestions = getTsServerSuggestions(cell.id);

return (
<div className={cn('font-mono text-sm', fullscreen && !show && 'border-b')}>
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as 'stdout' | 'stderr' | 'problems')}
onValueChange={(value) =>
setActiveTab(value as 'stdout' | 'stderr' | 'problems' | 'warnings')
}
defaultValue="stdout"
>
<div
Expand Down Expand Up @@ -94,6 +99,24 @@ export function CellOutput({
)}
</TabsTrigger>
)}
{cell.type === 'code' && cell.language === 'typescript' && (
<TabsTrigger
onClick={() => setShow(true)}
value="warnings"
className={cn(
!show &&
'border-transparent data-[state=active]:border-transparent data-[state=active]:text-tertiary-foreground mb-0',
)}
>
{suggestions.length > 0 ? (
<>
warnings <span className="text-sb-yellow-50">({suggestions.length})</span>
</>
) : (
'warnings'
)}
</TabsTrigger>
)}
</TabsList>
<div className="flex items-center gap-6">
<button
Expand All @@ -106,8 +129,13 @@ export function CellOutput({
</button>
<button
className="hover:text-secondary-hover disabled:pointer-events-none disabled:opacity-50"
disabled={activeTab === 'problems'}
onClick={() => clearOutput(cell.id, activeTab === 'problems' ? undefined : activeTab)}
disabled={activeTab === 'problems' || activeTab === 'warnings'}
onClick={() =>
clearOutput(
cell.id,
activeTab === 'problems' || activeTab === 'warnings' ? undefined : activeTab,
)
}
>
<Ban size={16} />
</button>
Expand Down Expand Up @@ -138,6 +166,15 @@ export function CellOutput({
/>
</TabsContent>
)}
{cell.type === 'code' && cell.language === 'typescript' && (
<TabsContent value="warnings" className="mt-0">
<TsServerSuggestions
suggestions={suggestions}
fixSuggestions={fixDiagnostics} // fixDiagnostics works for both diagnostics and suggestions
cellMode={cellMode}
/>
</TabsContent>
)}
</div>
)}
</Tabs>
Expand Down Expand Up @@ -203,3 +240,34 @@ function TsServerDiagnostics({
</div>
);
}

function TsServerSuggestions({
suggestions,
fixSuggestions,
cellMode,
}: {
suggestions: TsServerDiagnosticType[];
fixSuggestions: (suggestions: string) => void;
cellMode: 'off' | 'generating' | 'reviewing' | 'prompting' | 'fixing';
}) {
const { aiEnabled } = useSettings();
const formattedSuggestions = suggestions.map(formatDiagnostic).join('\n');
return suggestions.length === 0 ? (
<div className="italic text-center text-muted-foreground">No warnings or suggestions</div>
) : (
<div className="flex flex-col w-full">
<p>{formattedSuggestions}</p>
{aiEnabled && cellMode !== 'fixing' && (
<Button
variant="ai"
className="self-start flex items-center gap-2 px-2.5 py-2 font-sans h-7 mt-3"
onClick={() => fixSuggestions(formattedSuggestions)}
disabled={cellMode === 'generating'}
>
<Sparkles size={16} />
<p>Fix with AI</p>
</Button>
)}
</div>
);
}
88 changes: 87 additions & 1 deletion packages/web/src/components/cells/code.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
CodeCellUpdateAttrsType,
CellErrorPayloadType,
AiGeneratedCellPayloadType,
TsServerDiagnosticType,
} from '@srcbook/shared';
import { useSettings } from '@/components/use-settings';
import { cn } from '@/lib/utils';
Expand All @@ -41,6 +42,7 @@ import { useDebouncedCallback } from 'use-debounce';
import { EditorView } from 'codemirror';
import { EditorState } from '@codemirror/state';
import { unifiedMergeView } from '@codemirror/merge';
import { type Diagnostic, linter } from '@codemirror/lint';

const DEBOUNCE_DELAY = 500;
type CellModeType = 'off' | 'generating' | 'reviewing' | 'prompting' | 'fixing';
Expand Down Expand Up @@ -543,6 +545,84 @@ function Header(props: {
);
}

function tsCategoryToSeverity(
diagnostic: Pick<TsServerDiagnosticType, 'category' | 'code'>,
): Diagnostic['severity'] {
if (diagnostic.code === 7027) {
return 'warning';
}
// force resolve types with fallback
switch (diagnostic.category) {
case 'error':
return 'error';
case 'warning':
return 'warning';
case 'suggestion':
return 'warning';
case 'info':
return 'info';
default:
return 'info';
}
}

function isDiagnosticWithLocation(
diagnostic: TsServerDiagnosticType,
): diagnostic is TsServerDiagnosticType {
return !!(typeof diagnostic.start.line === 'number' && typeof diagnostic.end.line === 'number');
}

function tsDiagnosticMessage(diagnostic: TsServerDiagnosticType): string {
if (typeof diagnostic.text === 'string') {
return diagnostic.text;
}
return JSON.stringify(diagnostic); // Fallback
}

function convertTSDiagnosticToCM(diagnostic: TsServerDiagnosticType, code: string): Diagnostic {
const message = tsDiagnosticMessage(diagnostic);

// parse conversion TS server is {line, offset} to CodeMirror {from, to} in absolute chars
return {
from: Math.min(
code.length - 1,
code
.split('\n')
.slice(0, diagnostic.start.line - 1)
.join('\n').length + diagnostic.start.offset,
),
to: Math.min(
code.length - 1,
code
.split('\n')
.slice(0, diagnostic.end.line - 1)
.join('\n').length + diagnostic.end.offset,
),
message: message,
severity: tsCategoryToSeverity(diagnostic),
};
}

function tsLinter(
cell: CodeCellType,
getTsServerDiagnostics: (id: string) => TsServerDiagnosticType[],
getTsServerSuggestions: (id: string) => TsServerDiagnosticType[],
) {
const semanticDiagnostics = getTsServerDiagnostics(cell.id);
const syntaticDiagnostics = getTsServerSuggestions(cell.id);
const diagnostics = [...syntaticDiagnostics, ...semanticDiagnostics].filter(
isDiagnosticWithLocation,
);

const cm_diagnostics = diagnostics.map((diagnostic) => {
return convertTSDiagnosticToCM(diagnostic, cell.source);
});

return linter(async (): Promise<readonly Diagnostic[]> => {
return cm_diagnostics;
});
}

function CodeEditor({
cell,
runCell,
Expand All @@ -555,7 +635,11 @@ function CodeEditor({
readOnly: boolean;
}) {
const { codeTheme } = useTheme();
const { updateCell: updateCellOnClient } = useCells();
const {
updateCell: updateCellOnClient,
getTsServerDiagnostics,
getTsServerSuggestions,
} = useCells();

const updateCellOnServerDebounced = useDebouncedCallback(updateCellOnServer, DEBOUNCE_DELAY);

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

let extensions = [
javascript({ typescript: true }),
// wordHoverExtension,
tsLinter(cell, getTsServerDiagnostics, getTsServerSuggestions),
Prec.highest(keymap.of([{ key: 'Mod-Enter', run: evaluateModEnter }])),
];
if (readOnly) {
Expand Down
Loading

0 comments on commit 6793ebb

Please sign in to comment.