Skip to content

Commit e2161fb

Browse files
author
wangzhengfei
committed
2 parents 9409e20 + b0214c2 commit e2161fb

7 files changed

Lines changed: 785 additions & 15 deletions

File tree

Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
# SourceRef Drawer Implementation Plan
2+
3+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4+
5+
**Goal:** Replace `SourceRef`'s GitHub navigation with an in-site right drawer that loads and displays the linked source content.
6+
7+
**Architecture:** Keep `SourceRef`'s existing `href` generation unchanged, intercept the click in React, convert the GitHub page URL into a fetchable raw-content URL, and render the fetched text inside a client-side drawer. Keep drawer state and fetch lifecycle inside the component tree so MDX usage stays unchanged.
8+
9+
**Tech Stack:** Docusaurus 3, React 19, TypeScript, CSS modules
10+
11+
---
12+
13+
### Task 1: Add URL conversion tests
14+
15+
**Files:**
16+
- Create: `src/components/SourceRef/sourceUrl.test.ts`
17+
- Create: `src/components/SourceRef/sourceUrl.ts`
18+
19+
- [ ] **Step 1: Write the failing test**
20+
21+
```ts
22+
import {describe, expect, it} from 'node:test';
23+
import {githubUrlToRawUrl, parseLineAnchor} from './sourceUrl';
24+
25+
describe('githubUrlToRawUrl', () => {
26+
it('converts a GitHub tree URL to a raw content URL', () => {
27+
expect(
28+
githubUrlToRawUrl(
29+
'https://github.com/wzf1997/claude-code-source/tree/master/source/src/foo.ts',
30+
),
31+
).toBe(
32+
'https://raw.githubusercontent.com/wzf1997/claude-code-source/master/source/src/foo.ts',
33+
);
34+
});
35+
36+
it('strips the line anchor when converting the URL', () => {
37+
expect(
38+
githubUrlToRawUrl(
39+
'https://github.com/wzf1997/claude-code-source/tree/master/source/src/foo.ts#L12',
40+
),
41+
).toBe(
42+
'https://raw.githubusercontent.com/wzf1997/claude-code-source/master/source/src/foo.ts',
43+
);
44+
});
45+
});
46+
47+
describe('parseLineAnchor', () => {
48+
it('returns the start and end line from a GitHub anchor', () => {
49+
expect(parseLineAnchor('#L12-L16')).toEqual({start: 12, end: 16});
50+
});
51+
52+
it('returns null when the anchor is missing', () => {
53+
expect(parseLineAnchor('')).toBeNull();
54+
});
55+
});
56+
```
57+
58+
- [ ] **Step 2: Run test to verify it fails**
59+
60+
Run: `node --test src/components/SourceRef/sourceUrl.test.ts`
61+
Expected: FAIL with module-not-found or missing export errors for `./sourceUrl`
62+
63+
- [ ] **Step 3: Write minimal implementation**
64+
65+
```ts
66+
export function githubUrlToRawUrl(url: string): string {
67+
const parsed = new URL(url);
68+
const parts = parsed.pathname.split('/').filter(Boolean);
69+
70+
if (parts.length < 5 || parts[2] !== 'tree') {
71+
throw new Error('Unsupported GitHub source URL');
72+
}
73+
74+
const [owner, repo, , branch, ...fileParts] = parts;
75+
76+
return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${fileParts.join('/')}`;
77+
}
78+
79+
export function parseLineAnchor(hash: string): {start: number; end?: number} | null {
80+
const match = hash.match(/^#L(\d+)(?:-L(\d+))?$/);
81+
82+
if (!match) {
83+
return null;
84+
}
85+
86+
return {
87+
start: Number(match[1]),
88+
end: match[2] ? Number(match[2]) : undefined,
89+
};
90+
}
91+
```
92+
93+
- [ ] **Step 4: Run test to verify it passes**
94+
95+
Run: `node --test src/components/SourceRef/sourceUrl.test.ts`
96+
Expected: PASS with 4 passing assertions
97+
98+
- [ ] **Step 5: Commit**
99+
100+
```bash
101+
git add src/components/SourceRef/sourceUrl.ts src/components/SourceRef/sourceUrl.test.ts
102+
git commit -m "test: cover source ref URL conversion"
103+
```
104+
105+
### Task 2: Add the failing interaction test for the drawer
106+
107+
**Files:**
108+
- Modify: `package.json`
109+
- Create: `src/components/SourceRef/SourceRef.test.tsx`
110+
111+
- [ ] **Step 1: Write the failing test**
112+
113+
```tsx
114+
import React from 'react';
115+
import TestRenderer, {act} from 'react-test-renderer';
116+
import SourceRef from './index';
117+
118+
describe('SourceRef', () => {
119+
it('opens a drawer and keeps the anchor href unchanged on click', async () => {
120+
global.fetch = jest.fn().mockResolvedValue({
121+
ok: true,
122+
text: async () => 'const value = 1;',
123+
}) as typeof fetch;
124+
125+
const renderer = TestRenderer.create(
126+
<SourceRef file="source/src/foo.ts" lines="12-16" />,
127+
);
128+
129+
const link = renderer.root.findByType('a');
130+
expect(link.props.href).toBe(
131+
'https://github.com/wzf1997/claude-code-source/tree/master/source/src/foo.ts#L12-16',
132+
);
133+
134+
await act(async () => {
135+
link.props.onClick({
136+
preventDefault() {},
137+
});
138+
});
139+
140+
expect(renderer.root.findByProps({role: 'dialog'})).toBeTruthy();
141+
expect(renderer.root.findByProps({children: 'const value = 1;'})).toBeTruthy();
142+
});
143+
});
144+
```
145+
146+
- [ ] **Step 2: Run test to verify it fails**
147+
148+
Run: `pnpm exec jest src/components/SourceRef/SourceRef.test.tsx`
149+
Expected: FAIL because no test runner is configured yet
150+
151+
- [ ] **Step 3: Write minimal implementation support**
152+
153+
```json
154+
{
155+
"devDependencies": {
156+
"@types/jest": "^29.5.14",
157+
"@types/react-test-renderer": "^19.0.0",
158+
"jest": "^29.7.0",
159+
"react-test-renderer": "^19.0.0",
160+
"ts-jest": "^29.2.5"
161+
},
162+
"scripts": {
163+
"test": "jest"
164+
}
165+
}
166+
```
167+
168+
```js
169+
// jest.config.cjs
170+
module.exports = {
171+
preset: 'ts-jest',
172+
testEnvironment: 'jsdom',
173+
roots: ['<rootDir>/src'],
174+
moduleNameMapper: {
175+
'\\.module\\.css$': '<rootDir>/src/test/styleModuleStub.ts',
176+
},
177+
};
178+
```
179+
180+
- [ ] **Step 4: Run test to verify it still fails for the right reason**
181+
182+
Run: `pnpm test -- src/components/SourceRef/SourceRef.test.tsx`
183+
Expected: FAIL because `SourceRef` does not yet render a dialog or intercept clicks
184+
185+
- [ ] **Step 5: Commit**
186+
187+
```bash
188+
git add package.json pnpm-lock.yaml jest.config.cjs src/components/SourceRef/SourceRef.test.tsx src/test/styleModuleStub.ts
189+
git commit -m "test: add source ref drawer interaction coverage"
190+
```
191+
192+
### Task 3: Implement the drawer, fetch flow, and line highlighting
193+
194+
**Files:**
195+
- Modify: `src/components/SourceRef/index.tsx`
196+
- Modify: `src/components/SourceRef/styles.module.css`
197+
- Modify: `src/components/SourceRef/sourceUrl.ts`
198+
199+
- [ ] **Step 1: Write the minimal component implementation**
200+
201+
```tsx
202+
const [isOpen, setIsOpen] = useState(false);
203+
const [isLoading, setIsLoading] = useState(false);
204+
const [error, setError] = useState<string | null>(null);
205+
const [content, setContent] = useState('');
206+
207+
async function handleOpen(event: React.MouseEvent<HTMLAnchorElement>) {
208+
event.preventDefault();
209+
setIsOpen(true);
210+
setIsLoading(true);
211+
setError(null);
212+
213+
try {
214+
const response = await fetch(githubUrlToRawUrl(href));
215+
if (!response.ok) {
216+
throw new Error(`HTTP ${response.status}`);
217+
}
218+
setContent(await response.text());
219+
} catch (cause) {
220+
setError(cause instanceof Error ? cause.message : '加载源码失败');
221+
} finally {
222+
setIsLoading(false);
223+
}
224+
}
225+
```
226+
227+
```tsx
228+
{isOpen && (
229+
<>
230+
<button className={styles.backdrop} onClick={() => setIsOpen(false)} aria-label="关闭源码预览遮罩" />
231+
<aside className={styles.drawer} role="dialog" aria-modal="true" aria-label="源码预览">
232+
<div className={styles.drawerHeader}>
233+
<div>
234+
<div className={styles.drawerTitle}>{file}</div>
235+
{lineRange && <div className={styles.drawerMeta}>L{lineRange.start}{lineRange.end ? `-L${lineRange.end}` : ''}</div>}
236+
</div>
237+
<button className={styles.closeButton} onClick={() => setIsOpen(false)}>关闭</button>
238+
</div>
239+
<div className={styles.drawerBody}>
240+
{isLoading && <p className={styles.state}>加载中...</p>}
241+
{error && <p className={styles.stateError}>加载失败:{error}</p>}
242+
{!isLoading && !error && (
243+
<pre className={styles.codeBlock}>
244+
{content.split('\n').map((line, index) => {
245+
const lineNumber = index + 1;
246+
const active = lineRange && lineNumber >= lineRange.start && lineNumber <= (lineRange.end ?? lineRange.start);
247+
248+
return (
249+
<div key={lineNumber} className={active ? styles.codeLineActive : styles.codeLine}>
250+
<span className={styles.lineNumber}>{lineNumber}</span>
251+
<code>{line}</code>
252+
</div>
253+
);
254+
})}
255+
</pre>
256+
)}
257+
</div>
258+
</aside>
259+
</>
260+
)}
261+
```
262+
263+
- [ ] **Step 2: Run the focused tests**
264+
265+
Run: `pnpm test -- src/components/SourceRef/sourceUrl.test.ts src/components/SourceRef/SourceRef.test.tsx`
266+
Expected: PASS
267+
268+
- [ ] **Step 3: Add keyboard close behavior and target-line scroll**
269+
270+
```tsx
271+
useEffect(() => {
272+
if (!isOpen) {
273+
return;
274+
}
275+
276+
function handleKeyDown(event: KeyboardEvent) {
277+
if (event.key === 'Escape') {
278+
setIsOpen(false);
279+
}
280+
}
281+
282+
window.addEventListener('keydown', handleKeyDown);
283+
return () => window.removeEventListener('keydown', handleKeyDown);
284+
}, [isOpen]);
285+
```
286+
287+
```tsx
288+
useEffect(() => {
289+
if (!isOpen || !lineRange?.start) {
290+
return;
291+
}
292+
293+
lineRefs.current[lineRange.start]?.scrollIntoView({block: 'center'});
294+
}, [isOpen, content, lineRange]);
295+
```
296+
297+
- [ ] **Step 4: Run the focused tests again**
298+
299+
Run: `pnpm test -- src/components/SourceRef/sourceUrl.test.ts src/components/SourceRef/SourceRef.test.tsx`
300+
Expected: PASS
301+
302+
- [ ] **Step 5: Commit**
303+
304+
```bash
305+
git add src/components/SourceRef/index.tsx src/components/SourceRef/styles.module.css src/components/SourceRef/sourceUrl.ts
306+
git commit -m "feat: preview source refs in a drawer"
307+
```
308+
309+
### Task 4: Verify the site build and typecheck
310+
311+
**Files:**
312+
- Modify: `src/components/SourceRef/SourceRef.test.tsx`
313+
- Modify: `src/components/SourceRef/sourceUrl.test.ts`
314+
315+
- [ ] **Step 1: Align tests with the final implementation details**
316+
317+
```ts
318+
expect(screen.getByRole('dialog', {name: '源码预览'})).toBeInTheDocument();
319+
expect(screen.getByText('L12-L16')).toBeInTheDocument();
320+
```
321+
322+
- [ ] **Step 2: Run unit tests**
323+
324+
Run: `pnpm test -- --runInBand`
325+
Expected: PASS
326+
327+
- [ ] **Step 3: Run typecheck**
328+
329+
Run: `pnpm typecheck`
330+
Expected: PASS with no TypeScript errors
331+
332+
- [ ] **Step 4: Run production build**
333+
334+
Run: `pnpm build`
335+
Expected: PASS with static site output in `build/`
336+
337+
- [ ] **Step 5: Commit**
338+
339+
```bash
340+
git add src/components/SourceRef/SourceRef.test.tsx src/components/SourceRef/sourceUrl.test.ts package.json pnpm-lock.yaml
341+
git commit -m "chore: verify source ref drawer changes"
342+
```

0 commit comments

Comments
 (0)