feat: implement watch claude code project folders and sync state

This commit is contained in:
d-kimsuon
2025-08-31 16:37:53 +09:00
parent 14b074c03c
commit be9914670c
13 changed files with 503 additions and 8 deletions

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
};
export default nextConfig;

View File

@@ -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 .",

View 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}</>;
}

View File

@@ -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>

View File

@@ -12,6 +12,5 @@ export const useProject = (projectId: string) => {
return await response.json();
},
refetchOnReconnect: true,
refetchInterval: 10 * 1000,
});
};

View File

@@ -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"

View File

@@ -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">

View 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
View 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(),
};
}

View File

@@ -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("エラーが発生しました。");
},
);
});
};

View 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;
};

View 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);
};

View 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;
}