batch3 T6: usePanes hook + Shiki integration in CodeBlock
- hooks/usePanes: per-session panes CRUD; debounced (300ms) state PATCH; immediate position-change PATCH with refresh - CodeBlock: shiki async highlighting via codeToHtml + github-dark theme; LANG_MAP for ts/tsx/js/jsx/py/go/rs/rb/java/c/cpp/cs/php/sh/yaml/json/ toml/md/sql/dockerfile/html/css; falls back to plain pre on unknown lang or async failure - package.json: + shiki Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,7 @@
|
||||
"react-router-dom": "^6.26.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shadcn": "^4.7.0",
|
||||
"shiki": "^1.29.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
|
||||
@@ -1,16 +1,86 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { codeToHtml } from 'shiki';
|
||||
|
||||
// NOTE: spec calls for syntax-highlighted code blocks. Highlighting deferred
|
||||
// to keep dep footprint minimal; this renders styled mono code with a copy
|
||||
// button. Adding a highlighter (shiki / highlight.js) is a one-import swap.
|
||||
// NOTE: spec calls for syntax-highlighted code blocks. Added Shiki in v1.1.
|
||||
// Shiki output is compiler-generated and does not contain user input; setting
|
||||
// it via a ref is safe here.
|
||||
interface Props {
|
||||
code: string;
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
const LANG_MAP: Record<string, string> = {
|
||||
ts: 'typescript',
|
||||
tsx: 'tsx',
|
||||
typescript: 'typescript',
|
||||
js: 'javascript',
|
||||
jsx: 'jsx',
|
||||
javascript: 'javascript',
|
||||
py: 'python',
|
||||
python: 'python',
|
||||
go: 'go',
|
||||
rs: 'rust',
|
||||
rust: 'rust',
|
||||
rb: 'ruby',
|
||||
ruby: 'ruby',
|
||||
java: 'java',
|
||||
c: 'c',
|
||||
cpp: 'cpp',
|
||||
cs: 'csharp',
|
||||
csharp: 'csharp',
|
||||
php: 'php',
|
||||
sh: 'bash',
|
||||
bash: 'bash',
|
||||
shell: 'bash',
|
||||
yaml: 'yaml',
|
||||
yml: 'yaml',
|
||||
json: 'json',
|
||||
toml: 'toml',
|
||||
md: 'markdown',
|
||||
markdown: 'markdown',
|
||||
sql: 'sql',
|
||||
dockerfile: 'dockerfile',
|
||||
html: 'html',
|
||||
css: 'css',
|
||||
};
|
||||
|
||||
const SHIKI_THEME = 'github-dark';
|
||||
|
||||
export function CodeBlock({ code, lang }: Props) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [html, setHtml] = useState<string | null>(null);
|
||||
const highlightRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const mappedLang = (lang && LANG_MAP[lang.toLowerCase()]) ?? null;
|
||||
if (!mappedLang) {
|
||||
setHtml(null);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const result = await codeToHtml(code, { lang: mappedLang, theme: SHIKI_THEME });
|
||||
if (!cancelled) setHtml(result);
|
||||
} catch (err) {
|
||||
console.warn('shiki failed', err);
|
||||
if (!cancelled) setHtml(null);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [code, lang]);
|
||||
|
||||
// Inject Shiki HTML via ref; output is compiler-generated, not user input.
|
||||
useEffect(() => {
|
||||
if (highlightRef.current) {
|
||||
// Shiki generates sanitized HTML spans — not user-supplied content.
|
||||
// eslint-disable-next-line no-unsanitized/property
|
||||
highlightRef.current.innerHTML = html ?? '';
|
||||
}
|
||||
}, [html]);
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
@@ -36,9 +106,16 @@ export function CodeBlock({ code, lang }: Props) {
|
||||
<span>{copied ? 'Copied' : 'Copy'}</span>
|
||||
</button>
|
||||
</div>
|
||||
<pre className="overflow-x-auto px-3 py-2 font-mono text-xs leading-relaxed">
|
||||
{code}
|
||||
</pre>
|
||||
{html !== null ? (
|
||||
<div
|
||||
ref={highlightRef}
|
||||
className="overflow-x-auto px-3 py-2 font-mono text-xs leading-relaxed [&>pre]:!bg-transparent [&>pre]:!m-0 [&>pre]:!p-0"
|
||||
/>
|
||||
) : (
|
||||
<pre className="overflow-x-auto px-3 py-2 font-mono text-xs leading-relaxed">
|
||||
{code}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
145
apps/web/src/hooks/usePanes.ts
Normal file
145
apps/web/src/hooks/usePanes.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api } from '@/api/client';
|
||||
import type { Pane, PaneCreateRequest, PaneState, PaneUpdateRequest } from '@/api/types';
|
||||
|
||||
export function usePanes(sessionId: string | undefined): {
|
||||
panes: Pane[] | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
create: (body: PaneCreateRequest) => Promise<Pane>;
|
||||
update: (id: string, body: PaneUpdateRequest) => Promise<void>;
|
||||
remove: (id: string) => Promise<void>;
|
||||
} {
|
||||
const [panes, setPanes] = useState<Pane[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Pending debounced state PATCHes: pane id -> latest PaneState
|
||||
const pendingState = useRef<Map<string, PaneState>>(new Map());
|
||||
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const flushPendingState = useCallback(() => {
|
||||
if (debounceTimer.current !== null) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
debounceTimer.current = null;
|
||||
}
|
||||
const snapshot = new Map(pendingState.current);
|
||||
pendingState.current.clear();
|
||||
for (const [id, state] of snapshot) {
|
||||
// fire-and-forget; caller surface handles errors via hook error state
|
||||
void api.panes.update(id, { state }).catch((err) => {
|
||||
setError(err instanceof Error ? err.message : 'pane operation failed');
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!sessionId) {
|
||||
setPanes(null);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const { panes: list } = await api.panes.getForSession(sessionId);
|
||||
setPanes(list);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'pane operation failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
// Fetch on mount / sessionId change; preserve previous list while reloading
|
||||
// (loading=true but panes stays non-null after first fetch to avoid flash)
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// Flush debounced PATCHes on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
flushPendingState();
|
||||
};
|
||||
}, [flushPendingState]);
|
||||
|
||||
const create = useCallback(
|
||||
async (body: PaneCreateRequest): Promise<Pane> => {
|
||||
if (!sessionId) throw new Error('no session');
|
||||
const created = await api.panes.create(sessionId, body);
|
||||
await refresh();
|
||||
return created;
|
||||
},
|
||||
[sessionId, refresh]
|
||||
);
|
||||
|
||||
const update = useCallback(
|
||||
async (id: string, body: PaneUpdateRequest): Promise<void> => {
|
||||
const stateOnly = body.state !== undefined && body.position === undefined;
|
||||
|
||||
if (stateOnly) {
|
||||
// Optimistic local update
|
||||
setPanes((prev) => {
|
||||
if (!prev) return prev;
|
||||
let changed = false;
|
||||
const next = prev.map((pane) => {
|
||||
if (pane.id !== id) return pane;
|
||||
changed = true;
|
||||
// Narrow via discriminated union to satisfy TypeScript
|
||||
if (pane.kind === 'chat') {
|
||||
return { ...pane, state: body.state as typeof pane.state };
|
||||
}
|
||||
if (pane.kind === 'file_browser') {
|
||||
return { ...pane, state: body.state as typeof pane.state };
|
||||
}
|
||||
return pane;
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
|
||||
// Coalesce: last state wins within debounce window
|
||||
pendingState.current.set(id, body.state!);
|
||||
|
||||
if (debounceTimer.current !== null) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
debounceTimer.current = null;
|
||||
flushPendingState();
|
||||
}, 300);
|
||||
} else {
|
||||
// position involved — fire immediately
|
||||
try {
|
||||
await api.panes.update(id, body);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'pane operation failed');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
},
|
||||
[refresh, flushPendingState]
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
async (id: string): Promise<void> => {
|
||||
// Optimistic remove
|
||||
const previous = panes;
|
||||
setPanes((prev) => (prev ? prev.filter((p) => p.id !== id) : prev));
|
||||
|
||||
try {
|
||||
await api.panes.remove(id);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
// Rollback
|
||||
setPanes(previous);
|
||||
setError(err instanceof Error ? err.message : 'pane operation failed');
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[panes, refresh]
|
||||
);
|
||||
|
||||
return { panes, loading, error, refresh, create, update, remove };
|
||||
}
|
||||
Reference in New Issue
Block a user