mirror of
https://github.com/aljazceru/claude-code-viewer.git
synced 2025-12-24 08:44:21 +01:00
feat: implement watch claude code project folders and sync state
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "run-p 'dev:*'",
|
||||
"dev:next": "next dev -p 3400 --turbopack",
|
||||
"build": "next build",
|
||||
"build": "next build && cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/",
|
||||
"lint": "run-s 'lint:*'",
|
||||
"lint:biome-format": "biome format .",
|
||||
"lint:biome-lint": "biome check .",
|
||||
|
||||
13
src/app/components/ServerEventsProvider.tsx
Normal file
13
src/app/components/ServerEventsProvider.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useServerEvents } from "@/hooks/useServerEvents";
|
||||
|
||||
interface ServerEventsProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ServerEventsProvider({ children }: ServerEventsProviderProps) {
|
||||
useServerEvents();
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { QueryClientProviderWrapper } from "../lib/api/QueryClientProviderWrapper";
|
||||
import { RootErrorBoundary } from "./components/RootErrorBoundary";
|
||||
import { ServerEventsProvider } from "./components/ServerEventsProvider";
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
@@ -16,8 +17,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "Claude Code Viewer",
|
||||
description: "Web Viewer for Claude Code history",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -26,12 +27,14 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="ja">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<RootErrorBoundary>
|
||||
<QueryClientProviderWrapper>{children}</QueryClientProviderWrapper>
|
||||
<QueryClientProviderWrapper>
|
||||
<ServerEventsProvider>{children}</ServerEventsProvider>
|
||||
</QueryClientProviderWrapper>
|
||||
</RootErrorBoundary>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -12,6 +12,5 @@ export const useProject = (projectId: string) => {
|
||||
return await response.json();
|
||||
},
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: 10 * 1000,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { honoClient } from "../../../../../../lib/api/client";
|
||||
|
||||
export const useSessionQuery = (projectId: string, sessionId: string) => {
|
||||
return useSuspenseQuery({
|
||||
queryKey: ["conversations", sessionId],
|
||||
queryKey: ["sessions", sessionId],
|
||||
queryFn: async () => {
|
||||
const response = await honoClient.api.projects[":projectId"].sessions[
|
||||
":sessionId"
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { HistoryIcon } from "lucide-react";
|
||||
import { ProjectList } from "./components/ProjectList";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const fetchCache = "force-no-store";
|
||||
|
||||
export default async function ProjectsPage() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
|
||||
105
src/hooks/useServerEvents.ts
Normal file
105
src/hooks/useServerEvents.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { honoClient } from "../lib/api/client";
|
||||
import type { SSEEvent } from "../server/service/events/types";
|
||||
|
||||
type ParsedEvent = {
|
||||
event: string;
|
||||
data: SSEEvent;
|
||||
id: string;
|
||||
};
|
||||
|
||||
const parseSSEEvent = (text: string): ParsedEvent => {
|
||||
const lines = text.split("\n");
|
||||
const eventIndex = lines.findIndex((line) => line.startsWith("event:"));
|
||||
const dataIndex = lines.findIndex((line) => line.startsWith("data:"));
|
||||
const idIndex = lines.findIndex((line) => line.startsWith("id:"));
|
||||
|
||||
const endIndex = (index: number) => {
|
||||
const targets = [eventIndex, dataIndex, idIndex, lines.length].filter(
|
||||
(current) => current > index,
|
||||
);
|
||||
return Math.min(...targets);
|
||||
};
|
||||
|
||||
if (eventIndex === -1 || dataIndex === -1 || idIndex === -1) {
|
||||
console.error("failed", text);
|
||||
throw new Error("Failed to parse SSE event");
|
||||
}
|
||||
|
||||
const event = lines.slice(eventIndex, endIndex(eventIndex)).join("\n");
|
||||
const data = lines.slice(dataIndex, endIndex(dataIndex)).join("\n");
|
||||
const id = lines.slice(idIndex, endIndex(idIndex)).join("\n");
|
||||
|
||||
return {
|
||||
id: id.slice("id:".length).trim(),
|
||||
event: event.slice("event:".length).trim(),
|
||||
data: JSON.parse(
|
||||
data.slice(data.indexOf("{"), data.indexOf("}") + 1),
|
||||
) as SSEEvent,
|
||||
};
|
||||
};
|
||||
|
||||
const parseSSEEvents = (text: string): ParsedEvent[] => {
|
||||
const eventTexts = text
|
||||
.split("\n\n")
|
||||
.filter((eventText) => eventText.length > 0);
|
||||
|
||||
return eventTexts.map((eventText) => parseSSEEvent(eventText));
|
||||
};
|
||||
|
||||
let isInitialized = false;
|
||||
|
||||
export const useServerEvents = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const listener = useCallback(async () => {
|
||||
console.log("listening to events");
|
||||
const response = await honoClient.api.events.state_changes.$get();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch events");
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error("Failed to get reader");
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const events = parseSSEEvents(decoder.decode(value));
|
||||
|
||||
for (const event of events) {
|
||||
console.log("data", event);
|
||||
|
||||
if (event.data.type === "project_changed") {
|
||||
console.log("invalidating projects");
|
||||
await queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||
}
|
||||
|
||||
if (event.data.type === "session_changed") {
|
||||
console.log("invalidating sessions");
|
||||
await queryClient.invalidateQueries({ queryKey: ["sessions"] });
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialized === false) {
|
||||
void listener()
|
||||
.then(() => {
|
||||
console.log("registered events listener");
|
||||
isInitialized = true;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("failed to register events listener", error);
|
||||
isInitialized = true;
|
||||
});
|
||||
}
|
||||
}, [listener]);
|
||||
};
|
||||
110
src/lib/sse/sseClient.ts
Normal file
110
src/lib/sse/sseClient.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type {
|
||||
ProjectChangedData,
|
||||
SessionChangedData,
|
||||
} from "../../server/service/events/types";
|
||||
|
||||
export interface SSEEventHandlers {
|
||||
onProjectChanged?: (data: ProjectChangedData) => void;
|
||||
onSessionChanged?: (data: SessionChangedData) => void;
|
||||
onConnected?: () => void;
|
||||
onHeartbeat?: (timestamp: string) => void;
|
||||
onError?: (error: Event) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export class SSEClient {
|
||||
private eventSource: EventSource | null = null;
|
||||
private handlers: SSEEventHandlers;
|
||||
private url: string;
|
||||
|
||||
constructor(baseUrl: string = "", handlers: SSEEventHandlers = {}) {
|
||||
this.url = `${baseUrl}/api/events`;
|
||||
this.handlers = handlers;
|
||||
}
|
||||
|
||||
public connect(): void {
|
||||
if (this.eventSource) {
|
||||
this.disconnect();
|
||||
}
|
||||
|
||||
try {
|
||||
this.eventSource = new EventSource(this.url);
|
||||
|
||||
// 接続確認イベント
|
||||
this.eventSource.addEventListener("connected", (event) => {
|
||||
console.log("SSE Connected:", event.data);
|
||||
this.handlers.onConnected?.();
|
||||
});
|
||||
|
||||
// プロジェクト変更イベント
|
||||
this.eventSource.addEventListener("project_changed", (event) => {
|
||||
try {
|
||||
const data: ProjectChangedData = JSON.parse(event.data);
|
||||
console.log("Project changed:", data);
|
||||
this.handlers.onProjectChanged?.(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to parse project_changed event:", error);
|
||||
}
|
||||
});
|
||||
|
||||
// セッション変更イベント
|
||||
this.eventSource.addEventListener("session_changed", (event) => {
|
||||
try {
|
||||
const data: SessionChangedData = JSON.parse(event.data);
|
||||
console.log("Session changed:", data);
|
||||
this.handlers.onSessionChanged?.(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to parse session_changed event:", error);
|
||||
}
|
||||
});
|
||||
|
||||
// ハートビートイベント
|
||||
this.eventSource.addEventListener("heartbeat", (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
this.handlers.onHeartbeat?.(data.timestamp);
|
||||
} catch (error) {
|
||||
console.error("Failed to parse heartbeat event:", error);
|
||||
}
|
||||
});
|
||||
|
||||
// エラーハンドリング
|
||||
this.eventSource.onerror = (error) => {
|
||||
console.error("SSE Error:", error);
|
||||
this.handlers.onError?.(error);
|
||||
};
|
||||
|
||||
// 接続終了
|
||||
this.eventSource.onopen = () => {
|
||||
console.log("SSE Connection opened");
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to establish SSE connection:", error);
|
||||
this.handlers.onError?.(error as Event);
|
||||
}
|
||||
}
|
||||
|
||||
public disconnect(): void {
|
||||
if (this.eventSource) {
|
||||
this.eventSource.close();
|
||||
this.eventSource = null;
|
||||
console.log("SSE Connection closed");
|
||||
this.handlers.onClose?.();
|
||||
}
|
||||
}
|
||||
|
||||
public isConnected(): boolean {
|
||||
return this.eventSource?.readyState === EventSource.OPEN;
|
||||
}
|
||||
}
|
||||
|
||||
// React Hook example
|
||||
export function useSSE(handlers: SSEEventHandlers) {
|
||||
const client = new SSEClient(window?.location?.origin, handlers);
|
||||
|
||||
return {
|
||||
connect: () => client.connect(),
|
||||
disconnect: () => client.disconnect(),
|
||||
isConnected: () => client.isConnected(),
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import { getFileWatcher } from "../service/events/fileWatcher";
|
||||
import { sseEvent } from "../service/events/sseEvent";
|
||||
import type { WatcherEvent } from "../service/events/types";
|
||||
import { getProject } from "../service/project/getProject";
|
||||
import { getProjects } from "../service/project/getProjects";
|
||||
import { getSession } from "../service/session/getSession";
|
||||
@@ -26,6 +30,118 @@ export const routes = (app: HonoAppType) => {
|
||||
const { projectId, sessionId } = c.req.param();
|
||||
const { session } = await getSession(projectId, sessionId);
|
||||
return c.json({ session });
|
||||
})
|
||||
|
||||
.get("/events/state_changes", async (c) => {
|
||||
return streamSSE(
|
||||
c,
|
||||
async (stream) => {
|
||||
const fileWatcher = getFileWatcher();
|
||||
let isConnected = true;
|
||||
let eventId = 0;
|
||||
|
||||
// ハートビート設定
|
||||
const heartbeat = setInterval(() => {
|
||||
if (isConnected) {
|
||||
stream
|
||||
.writeSSE({
|
||||
data: sseEvent({
|
||||
type: "heartbeat",
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
event: "heartbeat",
|
||||
id: String(eventId++),
|
||||
})
|
||||
.catch(() => {
|
||||
console.warn("Failed to write SSE event");
|
||||
isConnected = false;
|
||||
onConnectionClosed();
|
||||
});
|
||||
}
|
||||
}, 30 * 1000);
|
||||
|
||||
// connection handling
|
||||
const abortController = new AbortController();
|
||||
let connectionResolve: ((value: undefined) => void) | undefined;
|
||||
const connectionPromise = new Promise<undefined>((resolve) => {
|
||||
connectionResolve = resolve;
|
||||
});
|
||||
|
||||
const onConnectionClosed = () => {
|
||||
isConnected = false;
|
||||
connectionResolve?.(undefined);
|
||||
abortController.abort();
|
||||
clearInterval(heartbeat);
|
||||
};
|
||||
|
||||
// 接続終了時のクリーンアップ
|
||||
stream.onAbort(() => {
|
||||
console.log("SSE connection aborted");
|
||||
onConnectionClosed();
|
||||
});
|
||||
|
||||
// イベントリスナーを登録
|
||||
console.log("Registering SSE event listeners");
|
||||
fileWatcher.on("project_changed", async (event: WatcherEvent) => {
|
||||
if (!isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.eventType !== "project_changed") {
|
||||
return;
|
||||
}
|
||||
|
||||
await stream
|
||||
.writeSSE({
|
||||
data: sseEvent({
|
||||
type: event.eventType,
|
||||
...event.data,
|
||||
}),
|
||||
event: event.eventType,
|
||||
id: String(eventId++),
|
||||
})
|
||||
.catch(() => {
|
||||
console.warn("Failed to write SSE event");
|
||||
onConnectionClosed();
|
||||
});
|
||||
});
|
||||
fileWatcher.on("session_changed", async (event: WatcherEvent) => {
|
||||
if (!isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
await stream
|
||||
.writeSSE({
|
||||
data: sseEvent({
|
||||
...event.data,
|
||||
type: event.eventType,
|
||||
}),
|
||||
event: event.eventType,
|
||||
id: String(eventId++),
|
||||
})
|
||||
.catch(() => {
|
||||
onConnectionClosed();
|
||||
});
|
||||
});
|
||||
|
||||
// 初期接続確認メッセージ
|
||||
await stream.writeSSE({
|
||||
data: sseEvent({
|
||||
type: "connected",
|
||||
message: "SSE connection established",
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
event: "connected",
|
||||
id: String(eventId++),
|
||||
});
|
||||
|
||||
await connectionPromise;
|
||||
},
|
||||
async (err, stream) => {
|
||||
console.error("Streaming error:", err);
|
||||
await stream.write("エラーが発生しました。");
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
83
src/server/service/events/fileWatcher.ts
Normal file
83
src/server/service/events/fileWatcher.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { type FSWatcher, watch } from "node:fs";
|
||||
import z from "zod";
|
||||
import { claudeProjectPath } from "../paths";
|
||||
import type { WatcherEvent } from "./types";
|
||||
|
||||
const fileRegExp = /(?<projectId>.*?)\/(?<sessionId>.*?)\.jsonl/;
|
||||
const fileRegExpGroupSchema = z.object({
|
||||
projectId: z.string(),
|
||||
sessionId: z.string(),
|
||||
});
|
||||
|
||||
export class FileWatcherService extends EventEmitter {
|
||||
private watcher: FSWatcher | null = null;
|
||||
private projectWatchers: Map<string, FSWatcher> = new Map();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.startWatching();
|
||||
}
|
||||
|
||||
private startWatching(): void {
|
||||
try {
|
||||
console.log("Starting file watcher on:", claudeProjectPath);
|
||||
// メインプロジェクトディレクトリを監視
|
||||
this.watcher = watch(
|
||||
claudeProjectPath,
|
||||
{ persistent: false, recursive: true },
|
||||
(eventType, filename) => {
|
||||
if (!filename) return;
|
||||
|
||||
const groups = fileRegExpGroupSchema.safeParse(
|
||||
filename.match(fileRegExp)?.groups,
|
||||
);
|
||||
|
||||
if (!groups.success) return;
|
||||
|
||||
const { projectId, sessionId } = groups.data;
|
||||
|
||||
this.emit("project_changed", {
|
||||
eventType: "project_changed",
|
||||
data: { projectId, fileEventType: eventType },
|
||||
} satisfies WatcherEvent);
|
||||
|
||||
this.emit("session_changed", {
|
||||
eventType: "session_changed",
|
||||
data: {
|
||||
projectId,
|
||||
sessionId,
|
||||
fileEventType: eventType,
|
||||
},
|
||||
} satisfies WatcherEvent);
|
||||
},
|
||||
);
|
||||
console.log("File watcher initialization completed");
|
||||
} catch (error) {
|
||||
console.error("Failed to start file watching:", error);
|
||||
}
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
if (this.watcher) {
|
||||
this.watcher.close();
|
||||
this.watcher = null;
|
||||
}
|
||||
|
||||
for (const [, watcher] of this.projectWatchers) {
|
||||
watcher.close();
|
||||
}
|
||||
this.projectWatchers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// シングルトンインスタンス
|
||||
let watcherInstance: FileWatcherService | null = null;
|
||||
|
||||
export const getFileWatcher = (): FileWatcherService => {
|
||||
if (!watcherInstance) {
|
||||
console.log("Creating new FileWatcher instance");
|
||||
watcherInstance = new FileWatcherService();
|
||||
}
|
||||
return watcherInstance;
|
||||
};
|
||||
13
src/server/service/events/sseEvent.ts
Normal file
13
src/server/service/events/sseEvent.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { BaseSSEEvent, SSEEvent } from "./types";
|
||||
|
||||
let eventId = 0;
|
||||
|
||||
export const sseEvent = <D extends Omit<SSEEvent, "id" | "timestamp">>(
|
||||
data: D,
|
||||
): string => {
|
||||
return JSON.stringify({
|
||||
...data,
|
||||
id: String(eventId++),
|
||||
timestamp: new Date().toISOString(),
|
||||
} satisfies D & BaseSSEEvent);
|
||||
};
|
||||
50
src/server/service/events/types.ts
Normal file
50
src/server/service/events/types.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { WatchEventType } from "node:fs";
|
||||
|
||||
export type WatcherEvent =
|
||||
| {
|
||||
eventType: "project_changed";
|
||||
data: ProjectChangedData;
|
||||
}
|
||||
| {
|
||||
eventType: "session_changed";
|
||||
data: SessionChangedData;
|
||||
};
|
||||
|
||||
export type BaseSSEEvent = {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
export type SSEEvent = BaseSSEEvent &
|
||||
(
|
||||
| {
|
||||
type: "connected";
|
||||
message: string;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "heartbeat";
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
type: "project_changed";
|
||||
data: ProjectChangedData;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
type: "session_changed";
|
||||
data: SessionChangedData;
|
||||
}
|
||||
);
|
||||
|
||||
export interface ProjectChangedData {
|
||||
projectId: string;
|
||||
fileEventType: WatchEventType;
|
||||
}
|
||||
|
||||
export interface SessionChangedData {
|
||||
projectId: string;
|
||||
sessionId: string;
|
||||
fileEventType: WatchEventType;
|
||||
}
|
||||
Reference in New Issue
Block a user