Skip to content

Commit c0318b5

Browse files
authored
refactor: deduplicate webview UI code (#100)
* refactor: extract useClickOutside hook from 3 duplicate implementations * refactor: extract getLatestById and transformFeedbacksToTestCases utils Move duplicated submission/result extraction and feedback transformation logic from ExerciseDetailView and ExamExerciseDetailView into shared utilities in exerciseStatus.ts. * refactor: extract StatusMessage component from Login and GitCredentials views Replaces inline status div implementations (with hardcoded vscode variables and ternary style logic) with a reusable StatusMessage component that uses the project's theme tokens from base.css. Component carries role="status" and aria-live="polite" for correct accessibility semantics. * refactor: extract shared richContent CSS from MessageBubble and StreamingMessage * refactor: extract useStreamdownConfig hook and formatRelativeTime util Eliminate duplicated Streamdown code-block config (~20 lines) shared between MessageBubble and StreamingMessage into a single reusable hook. Extract the duplicated relative-time formatting logic from MessageBubble and ContextSelector into a shared formatRelativeTime util.
1 parent d646a30 commit c0318b5

20 files changed

Lines changed: 285 additions & 305 deletions
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
.statusMessage {
2+
padding: 8px 12px;
3+
border-radius: 4px;
4+
font-size: 13px;
5+
border: 1px solid;
6+
}
7+
8+
.success {
9+
background-color: var(--theme-success-background);
10+
color: var(--theme-success-foreground);
11+
border-color: var(--theme-success-border);
12+
}
13+
14+
.error {
15+
background-color: var(--theme-error-background);
16+
color: var(--theme-error-foreground);
17+
border-color: var(--theme-error-border);
18+
}
19+
20+
.warning {
21+
background-color: var(--theme-warning-background);
22+
color: var(--theme-warning-foreground);
23+
border-color: var(--theme-warning-border);
24+
}
25+
26+
.info {
27+
background-color: var(--theme-info-background);
28+
color: var(--theme-info-foreground);
29+
border-color: var(--theme-info-border);
30+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import styles from './StatusMessage.module.css';
2+
import clsx from 'clsx';
3+
4+
export type StatusType = 'success' | 'error' | 'warning' | 'info';
5+
6+
export interface StatusMessageProps {
7+
message: string;
8+
type: StatusType;
9+
'data-testid'?: string;
10+
}
11+
12+
export function StatusMessage({ message, type, 'data-testid': testId }: StatusMessageProps) {
13+
return (
14+
<div
15+
role="status"
16+
aria-live="polite"
17+
className={clsx(styles.statusMessage, styles[type])}
18+
data-testid={testId}
19+
>
20+
{message}
21+
</div>
22+
);
23+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { StatusMessage } from './StatusMessage';
2+
export type { StatusMessageProps, StatusType } from './StatusMessage';

extension/src/webview/components/exercise/ParticipationActions.tsx

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ReactNode, useState, useRef, useEffect } from 'react';
22
import clsx from 'clsx';
3+
import { useClickOutside } from '../../hooks/useClickOutside';
34
import FlaskConical from 'lucide-react/dist/esm/icons/flask-conical';
45
import AlertTriangle from 'lucide-react/dist/esm/icons/alert-triangle';
56
import Mail from 'lucide-react/dist/esm/icons/mail';
@@ -80,27 +81,19 @@ export function ParticipationActions({
8081
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
8182
const moreMenuRef = useRef<HTMLDivElement>(null);
8283

83-
useEffect(() => {
84-
if (!isDropdownOpen) {return;}
84+
useClickOutside(moreMenuRef, isDropdownOpen, () => setIsDropdownOpen(false));
8585

86-
const handleClickOutside = (e: MouseEvent) => {
87-
if (moreMenuRef.current && !moreMenuRef.current.contains(e.target as Node)) {
88-
setIsDropdownOpen(false);
89-
}
90-
};
86+
useEffect(() => {
87+
if (!isDropdownOpen) { return; }
9188

9289
const handleEscape = (e: KeyboardEvent) => {
9390
if (e.key === 'Escape') {
9491
setIsDropdownOpen(false);
9592
}
9693
};
9794

98-
document.addEventListener('mousedown', handleClickOutside);
9995
document.addEventListener('keydown', handleEscape);
100-
return () => {
101-
document.removeEventListener('mousedown', handleClickOutside);
102-
document.removeEventListener('keydown', handleEscape);
103-
};
96+
return () => document.removeEventListener('keydown', handleEscape);
10497
}, [isDropdownOpen]);
10598

10699
// Participation info section

extension/src/webview/components/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ export type { ErrorMessageProps } from './ErrorMessage';
7171
export { EmptyState } from './EmptyState';
7272
export type { EmptyStateProps } from './EmptyState';
7373

74+
export { StatusMessage } from './StatusMessage';
75+
export type { StatusMessageProps, StatusType } from './StatusMessage';
76+
7477
export { PageHeader } from './PageHeader';
7578
export type { PageHeaderProps } from './PageHeader';
7679

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { useEffect, useRef, type RefObject } from 'react';
2+
3+
/**
4+
* Dismisses a dropdown/popover when the user clicks outside the referenced element.
5+
* Attaches mousedown listener only while `isOpen` is true.
6+
*/
7+
export function useClickOutside(
8+
ref: RefObject<HTMLElement | null>,
9+
isOpen: boolean,
10+
onClose: () => void,
11+
): void {
12+
const onCloseRef = useRef(onClose);
13+
onCloseRef.current = onClose;
14+
15+
useEffect(() => {
16+
if (!isOpen) { return; }
17+
18+
const handleClickOutside = (event: MouseEvent) => {
19+
if (ref.current && !ref.current.contains(event.target as Node)) {
20+
onCloseRef.current();
21+
}
22+
};
23+
24+
document.addEventListener('mousedown', handleClickOutside);
25+
return () => document.removeEventListener('mousedown', handleClickOutside);
26+
}, [isOpen, ref]);
27+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { useMemo, type ReactNode } from 'react';
2+
import { CodeBlock } from '../views/IrisChat/components/CodeBlock';
3+
4+
/**
5+
* Shared Streamdown component configuration for rendering fenced code blocks
6+
* via the CodeBlock component. Used by both MessageBubble (static) and
7+
* StreamingMessage (streaming).
8+
*/
9+
export function useStreamdownConfig() {
10+
return useMemo(() => ({
11+
code: ({ node, className, children, ...props }: {
12+
node?: unknown;
13+
className?: string;
14+
children?: ReactNode;
15+
[key: string]: unknown;
16+
}) => {
17+
const match = /language-(\w+)/.exec(className || '');
18+
const language = match ? match[1] : undefined;
19+
20+
if (language || className?.includes('language-')) {
21+
return (
22+
<CodeBlock language={language}>
23+
{String(children).replace(/\n$/, '')}
24+
</CodeBlock>
25+
);
26+
}
27+
28+
return (
29+
<code className={className} {...props}>
30+
{children}
31+
</code>
32+
);
33+
},
34+
}), []);
35+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
.richContent {
2+
color: inherit;
3+
line-height: 1.6;
4+
}
5+
6+
.richContent p {
7+
margin: 0 0 12px 0;
8+
}
9+
10+
.richContent p:last-child {
11+
margin-bottom: 0;
12+
}
13+
14+
.richContent ul,
15+
.richContent ol {
16+
margin: 8px 0;
17+
padding-left: 24px;
18+
}
19+
20+
.richContent li {
21+
margin: 4px 0;
22+
}
23+
24+
.richContent code {
25+
background: var(--vscode-textCodeBlock-background);
26+
color: var(--vscode-textPreformat-foreground);
27+
padding: 2px 6px;
28+
border-radius: 3px;
29+
font-family: var(--vscode-editor-font-family);
30+
font-size: 0.9em;
31+
}
32+
33+
.richContent a {
34+
color: var(--vscode-textLink-foreground);
35+
text-decoration: none;
36+
}
37+
38+
.richContent a:hover {
39+
text-decoration: underline;
40+
}
41+
42+
.richContent blockquote {
43+
margin: 12px 0;
44+
padding-left: 12px;
45+
border-left: 3px solid var(--vscode-textBlockQuote-border);
46+
color: var(--vscode-textBlockQuote-foreground);
47+
background: var(--vscode-textBlockQuote-background);
48+
}
49+
50+
.richContent h1,
51+
.richContent h2,
52+
.richContent h3,
53+
.richContent h4,
54+
.richContent h5,
55+
.richContent h6 {
56+
margin: 16px 0 8px 0;
57+
font-weight: 600;
58+
line-height: 1.3;
59+
}
60+
61+
.richContent h1 { font-size: 1.5em; }
62+
.richContent h2 { font-size: 1.3em; }
63+
.richContent h3 { font-size: 1.1em; }

extension/src/webview/utils/exerciseStatus.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,43 @@ export function determineParticipationStatus(
4646
}
4747
return 'in-progress';
4848
}
49+
50+
/**
51+
* Extracts the latest item from an array by highest ID.
52+
* Artemis represents "latest" as highest ID, not chronological.
53+
*/
54+
export function getLatestById<T extends { id?: number }>(
55+
items: T[] | undefined,
56+
): T | undefined {
57+
if (!items || items.length === 0) { return undefined; }
58+
return [...items].sort((a, b) => (b.id ?? 0) - (a.id ?? 0))[0];
59+
}
60+
61+
interface TestCaseResult {
62+
name: string;
63+
passed: boolean;
64+
message?: string;
65+
}
66+
67+
interface FeedbackInput {
68+
type?: string;
69+
text?: string;
70+
positive?: boolean;
71+
detailText?: string;
72+
testCase?: { testName?: string };
73+
}
74+
75+
/**
76+
* Transforms Artemis result feedbacks into structured test case results.
77+
* Filters out SCA feedback identifiers and keeps only test-related entries.
78+
*/
79+
export function transformFeedbacksToTestCases(feedbacks: FeedbackInput[]): TestCaseResult[] {
80+
const testFeedbacks = feedbacks.filter(f =>
81+
f.testCase?.testName || ((!f.type || f.type === 'AUTOMATIC') && f.text && !f.text.startsWith('SCAFeedbackIdentifier:'))
82+
);
83+
return testFeedbacks.map(f => ({
84+
name: f.testCase?.testName ?? f.text ?? 'Test',
85+
passed: f.positive ?? false,
86+
message: f.detailText,
87+
}));
88+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* Formats a timestamp as a relative time string ("just now", "5m ago", "2h ago", "3d ago").
3+
* For use in non-hook contexts (e.g., inside .map() calls).
4+
* For component-level relative time with auto-refresh, use useRelativeTime hook instead.
5+
*/
6+
export function formatRelativeTime(timestamp: number): string {
7+
const diff = Date.now() - timestamp;
8+
const seconds = Math.floor(diff / 1000);
9+
const minutes = Math.floor(seconds / 60);
10+
const hours = Math.floor(minutes / 60);
11+
const days = Math.floor(hours / 24);
12+
13+
if (seconds < 60) { return 'just now'; }
14+
if (minutes < 60) { return `${minutes}m ago`; }
15+
if (hours < 24) { return `${hours}h ago`; }
16+
return `${days}d ago`;
17+
}

0 commit comments

Comments
 (0)