diff --git a/package.json b/package.json index efab3cb..c99fdd3 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.85.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b275ac..0bf25bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: '@radix-ui/react-tabs': specifier: ^1.1.13 version: 1.1.13(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-tooltip': + specifier: ^1.2.8 + version: 1.2.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@tanstack/react-query': specifier: ^5.85.5 version: 5.85.5(react@19.1.1) @@ -1319,6 +1322,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-tooltip@1.2.8': + resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-use-callback-ref@1.1.1': resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} peerDependencies: @@ -4570,6 +4586,26 @@ snapshots: '@types/react': 19.1.12 '@types/react-dom': 19.1.9(@types/react@19.1.12) + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.12)(react@19.1.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.12)(react@19.1.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.12 + '@types/react-dom': 19.1.9(@types/react@19.1.12) + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.1.12)(react@19.1.1)': dependencies: react: 19.1.1 diff --git a/src/app/components/MarkdownContent.tsx b/src/app/components/MarkdownContent.tsx index 9b7d2d9..faa038d 100644 --- a/src/app/components/MarkdownContent.tsx +++ b/src/app/components/MarkdownContent.tsx @@ -175,6 +175,8 @@ export const MarkdownContent: FC = ({ return ( diff --git a/src/app/projects/[projectId]/latest/page.tsx b/src/app/projects/[projectId]/latest/page.tsx index 18e3e68..1f3392b 100644 --- a/src/app/projects/[projectId]/latest/page.tsx +++ b/src/app/projects/[projectId]/latest/page.tsx @@ -21,7 +21,5 @@ export default async function LatestSessionPage({ redirect(`/projects`); } - redirect( - `/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(latestSession.id)}`, - ); + redirect(`/projects/${projectId}/sessions/${latestSession.id}`); } diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index 16720b6..e882cef 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -6,5 +6,5 @@ interface ProjectPageProps { export default async function ProjectPage({ params }: ProjectPageProps) { const { projectId } = await params; - redirect(`/projects/${encodeURIComponent(projectId)}/latest`); + redirect(`/projects/${projectId}/latest`); } diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx index 461c563..8b0d5a6 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx @@ -1,11 +1,24 @@ "use client"; -import { MessageSquareIcon, PlugIcon, SettingsIcon, XIcon } from "lucide-react"; +import { + ArrowLeftIcon, + MessageSquareIcon, + PlugIcon, + SettingsIcon, + XIcon, +} from "lucide-react"; +import Link from "next/link"; import { type FC, Suspense, useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { NotificationSettings } from "@/components/NotificationSettings"; import { SettingsControls } from "@/components/SettingsControls"; import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { useProject } from "../../../../hooks/useProject"; import { McpTab } from "./McpTab"; @@ -157,52 +170,89 @@ export const MobileSidebar: FC = ({ > {/* Tab Icons */}
-
- + + + + + + + + +

プロジェクト一覧に戻る

+
+
- +
+ + + + + +

セッション一覧を表示

+
+
- -
+ + + + + +

MCPサーバー設定を表示

+
+
+ + + + + + +

表示と通知の設定

+
+
+
+
{/* Content Area */} diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionSidebar.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionSidebar.tsx index 3bda11a..c07be34 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionSidebar.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionSidebar.tsx @@ -1,9 +1,16 @@ "use client"; -import { MessageSquareIcon, PlugIcon } from "lucide-react"; +import { ArrowLeftIcon, MessageSquareIcon, PlugIcon } from "lucide-react"; +import Link from "next/link"; import { type FC, useMemo } from "react"; import type { SidebarTab } from "@/components/GlobalSidebar"; import { GlobalSidebar } from "@/components/GlobalSidebar"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { useProject } from "../../../../hooks/useProject"; import { McpTab } from "./McpTab"; @@ -36,7 +43,7 @@ export const SessionSidebar: FC<{ { id: "sessions", icon: MessageSquareIcon, - title: "Sessions", + title: "セッション一覧を表示", content: ( ({ @@ -54,7 +61,7 @@ export const SessionSidebar: FC<{ { id: "mcp", icon: PlugIcon, - title: "MCP Servers", + title: "MCPサーバー設定を表示", content: , }, ], @@ -76,6 +83,23 @@ export const SessionSidebar: FC<{ projectId={projectId} additionalTabs={additionalTabs} defaultActiveTab="sessions" + headerButton={ + + + + + + + + +

プロジェクト一覧に戻る

+
+
+
+ } /> diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionsTab.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionsTab.tsx index 92e2613..47dd1be 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionsTab.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionsTab.tsx @@ -99,9 +99,7 @@ export const SessionsTab: FC<{ return ( { + const [open, setOpen] = useState(false); + const [selectedPath, setSelectedPath] = useState(""); + const router = useRouter(); + + const createProjectMutation = useMutation({ + mutationFn: async () => { + const response = await honoClient.api.projects.$post({ + json: { projectPath: selectedPath }, + }); + + if (!response.ok) { + throw new Error("Failed to create project"); + } + + return await response.json(); + }, + + onSuccess: (result) => { + toast.success("Project created successfully"); + setOpen(false); + router.push(`/projects/${result.projectId}/sessions/${result.sessionId}`); + }, + + onError: (error) => { + toast.error( + error instanceof Error ? error.message : "Failed to create project", + ); + }, + }); + + return ( + + + + + + + Create New Project + + Select a directory to initialize as a Claude Code project. This will + run{" "} + /init{" "} + in the selected directory. + + +
+ + {selectedPath ? ( +
+

Selected directory:

+

+ {selectedPath} +

+
+ ) : null} +
+ + + + +
+
+ ); +}; diff --git a/src/app/projects/components/DirectoryPicker.tsx b/src/app/projects/components/DirectoryPicker.tsx new file mode 100644 index 0000000..ba3129b --- /dev/null +++ b/src/app/projects/components/DirectoryPicker.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { ChevronRight, Folder } from "lucide-react"; +import { type FC, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { directoryListingQuery } from "@/lib/api/queries"; + +export type DirectoryPickerProps = { + selectedPath: string; + onPathChange: (path: string) => void; +}; + +export const DirectoryPicker: FC = ({ onPathChange }) => { + const [currentPath, setCurrentPath] = useState(undefined); + + const { data, isLoading } = useQuery(directoryListingQuery(currentPath)); + + const handleNavigate = (entryPath: string) => { + if (entryPath === "") { + setCurrentPath(undefined); + return; + } + + const newPath = `/${entryPath}`; + setCurrentPath(newPath); + }; + + const handleSelect = () => { + onPathChange(data?.currentPath || ""); + }; + + return ( +
+
+

+ Current: {data?.currentPath || "~"} +

+ +
+
+ {isLoading ? ( +
+ Loading... +
+ ) : data?.entries && data.entries.length > 0 ? ( +
+ {data.entries + .filter((entry) => entry.type === "directory") + .map((entry) => ( + + ))} +
+ ) : ( +
+ No directories found +
+ )} +
+
+ ); +}; diff --git a/src/app/projects/components/ProjectList.tsx b/src/app/projects/components/ProjectList.tsx index e4e10d9..156a23c 100644 --- a/src/app/projects/components/ProjectList.tsx +++ b/src/app/projects/components/ProjectList.tsx @@ -59,7 +59,7 @@ export const ProjectList: FC = () => { diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx index 38f4c8a..232dabe 100644 --- a/src/app/projects/page.tsx +++ b/src/app/projects/page.tsx @@ -3,6 +3,7 @@ import { HistoryIcon } from "lucide-react"; import { Suspense } from "react"; import { GlobalSidebar } from "@/components/GlobalSidebar"; +import { CreateProjectDialog } from "./components/CreateProjectDialog"; import { ProjectList } from "./components/ProjectList"; export const dynamic = "force-dynamic"; @@ -27,7 +28,10 @@ export default function ProjectsPage() {
-

Your Projects

+
+

Your Projects

+ +
diff --git a/src/components/GlobalSidebar.tsx b/src/components/GlobalSidebar.tsx index 4834d2f..3e2e9f4 100644 --- a/src/components/GlobalSidebar.tsx +++ b/src/components/GlobalSidebar.tsx @@ -3,6 +3,12 @@ import type { LucideIcon } from "lucide-react"; import { SettingsIcon } from "lucide-react"; import { type FC, type ReactNode, Suspense, useState } from "react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { NotificationSettings } from "./NotificationSettings"; import { SettingsControls } from "./SettingsControls"; @@ -19,6 +25,7 @@ interface GlobalSidebarProps { className?: string; additionalTabs?: SidebarTab[]; defaultActiveTab?: string; + headerButton?: ReactNode; } export const GlobalSidebar: FC = ({ @@ -26,11 +33,12 @@ export const GlobalSidebar: FC = ({ className, additionalTabs = [], defaultActiveTab, + headerButton, }) => { const settingsTab: SidebarTab = { id: "settings", icon: SettingsIcon, - title: "Settings", + title: "表示と通知の設定", content: (
@@ -96,29 +104,39 @@ export const GlobalSidebar: FC = ({ > {/* Vertical Icon Menu - Always Visible */}
-
- {allTabs.map((tab) => { - const Icon = tab.icon; - return ( - - ); - })} -
+ + {headerButton && ( +
{headerButton}
+ )} +
+ {allTabs.map((tab) => { + const Icon = tab.icon; + return ( + + + + + +

{tab.title}

+
+
+ ); + })} +
+
{/* Content Area - Only shown when expanded */} diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..ed700c2 --- /dev/null +++ b/src/components/ui/tooltip.tsx @@ -0,0 +1,61 @@ +"use client"; + +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function TooltipProvider({ + delayDuration = 0, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function Tooltip({ + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function TooltipTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ); +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/src/hooks/usePermissionRequests.ts b/src/hooks/usePermissionRequests.ts index d28f485..ce690ac 100644 --- a/src/hooks/usePermissionRequests.ts +++ b/src/hooks/usePermissionRequests.ts @@ -15,7 +15,7 @@ export const usePermissionRequests = () => { const [isDialogOpen, setIsDialogOpen] = useState(false); // Listen for permission requests from the server - useServerEventListener("permission_requested", (data) => { + useServerEventListener("permissionRequested", (data) => { if (data.permissionRequest) { setCurrentPermissionRequest(data.permissionRequest); setIsDialogOpen(true); diff --git a/src/lib/api/QueryClientProviderWrapper.tsx b/src/lib/api/QueryClientProviderWrapper.tsx index 7176030..b6972a8 100644 --- a/src/lib/api/QueryClientProviderWrapper.tsx +++ b/src/lib/api/QueryClientProviderWrapper.tsx @@ -23,7 +23,6 @@ export const makeQueryClient = () => defaultOptions: { queries: { refetchOnWindowFocus: true, - refetchInterval: 1000 * 60 * 5, retry: false, }, }, diff --git a/src/lib/api/queries.ts b/src/lib/api/queries.ts index 9abf7fe..c1b0adf 100644 --- a/src/lib/api/queries.ts +++ b/src/lib/api/queries.ts @@ -1,3 +1,4 @@ +import type { DirectoryListingResult } from "../../server/service/directory-browser/getDirectoryListing"; import type { FileCompletionResult } from "../../server/service/file-completion/getFileCompletion"; import { honoClient } from "./client"; @@ -16,6 +17,22 @@ export const projectListQuery = { }, } as const; +export const directoryListingQuery = (currentPath?: string) => + ({ + queryKey: ["directory-listing", currentPath], + queryFn: async (): Promise => { + const response = await honoClient.api["directory-browser"].$get({ + query: currentPath ? { currentPath } : {}, + }); + + if (!response.ok) { + throw new Error("Failed to fetch directory listing"); + } + + return await response.json(); + }, + }) as const; + export const projectDetailQuery = (projectId: string, cursor?: string) => ({ queryKey: ["projects", projectId], @@ -70,7 +87,7 @@ export const sessionDetailQuery = (projectId: string, sessionId: string) => throw new Error(`Failed to fetch session: ${response.statusText}`); } - return response.json(); + return await response.json(); }, }) as const; diff --git a/src/server/hono/route.ts b/src/server/hono/route.ts index 8912666..6dd0909 100644 --- a/src/server/hono/route.ts +++ b/src/server/hono/route.ts @@ -1,4 +1,5 @@ import { readdir } from "node:fs/promises"; +import { homedir } from "node:os"; import { resolve } from "node:path"; import type { CommandExecutor, FileSystem, Path } from "@effect/platform"; import { zValidator } from "@hono/zod-validator"; @@ -12,6 +13,8 @@ import { configSchema } from "../config/config"; import { env } from "../lib/env"; import { ClaudeCodeLifeCycleService } from "../service/claude-code/ClaudeCodeLifeCycleService"; import { ClaudeCodePermissionService } from "../service/claude-code/ClaudeCodePermissionService"; +import { computeClaudeProjectFilePath } from "../service/claude-code/computeClaudeProjectFilePath"; +import { getDirectoryListing } from "../service/directory-browser/getDirectoryListing"; import { adaptInternalEventToSSE } from "../service/events/adaptInternalEventToSSE"; import { EventBus } from "../service/events/EventBus"; import type { InternalEventDeclaration } from "../service/events/InternalEventDeclaration"; @@ -22,6 +25,7 @@ import { getCommits } from "../service/git/getCommits"; import { getDiff } from "../service/git/getDiff"; import { getMcpList } from "../service/mcp/getMcpList"; import { claudeCommandsDirPath } from "../service/paths"; +import { encodeProjectId } from "../service/project/id"; import type { ProjectMetaService } from "../service/project/ProjectMetaService"; import { ProjectRepository } from "../service/project/ProjectRepository"; import type { SessionMetaService } from "../service/session/SessionMetaService"; @@ -92,6 +96,87 @@ export const routes = (app: HonoAppType) => return c.json({ projects }); }) + .post( + "/projects", + zValidator( + "json", + z.object({ + projectPath: z.string().min(1, "Project path is required"), + }), + ), + async (c) => { + const { projectPath } = c.req.valid("json"); + + // No project validation needed - startTask will create a new project + // if it doesn't exist when running /init command + const claudeProjectFilePath = + computeClaudeProjectFilePath(projectPath); + const projectId = encodeProjectId(claudeProjectFilePath); + + const program = Effect.gen(function* () { + const result = yield* claudeCodeLifeCycleService.startTask({ + baseSession: { + cwd: projectPath, + projectId, + sessionId: undefined, + }, + config: c.get("config"), + message: "/init", + }); + + return { + result, + status: 200 as const, + }; + }); + + const result = await Runtime.runPromise(runtime)(program); + + if (result.status === 200) { + const { sessionId } = + await result.result.awaitSessionFileCreated(); + + return c.json({ + projectId: result.result.sessionProcess.def.projectId, + sessionId, + }); + } + + return c.json({ error: "Failed to create project" }, 500); + }, + ) + + .get( + "/directory-browser", + zValidator( + "query", + z.object({ + currentPath: z.string().optional(), + }), + ), + async (c) => { + const { currentPath } = c.req.valid("query"); + const rootPath = "/"; + const defaultPath = homedir(); + + try { + const targetPath = currentPath || defaultPath; + const relativePath = targetPath.startsWith(rootPath) + ? targetPath.slice(rootPath.length) + : targetPath; + + const result = await getDirectoryListing(rootPath, relativePath); + return c.json(result); + } catch (error) { + console.error("Directory listing error:", error); + if (error instanceof Error) { + return c.json({ error: error.message }, 400); + } + return c.json({ error: "Failed to list directory" }, 500); + } + }, + ) + .get( "/projects/:projectId", zValidator("query", z.object({ cursor: z.string().optional() })), @@ -172,10 +257,11 @@ export const routes = (app: HonoAppType) => filteredSessions = Array.from(sessionMap.values()); } + const hasMore = sessions.length >= 20; return { project, sessions: filteredSessions, - nextCursor: sessions.at(-1)?.id, + nextCursor: hasMore ? sessions.at(-1)?.id : undefined, }; }); @@ -498,11 +584,14 @@ export const routes = (app: HonoAppType) => const result = await Runtime.runPromise(runtime)(program); if (result.status === 200) { + const { sessionId } = + await result.result.awaitSessionInitialized(); + return c.json({ sessionProcess: { id: result.result.sessionProcess.def.sessionProcessId, projectId: result.result.sessionProcess.def.projectId, - sessionId: await result.result.awaitSessionInitialized(), + sessionId, }, }); } @@ -648,6 +737,16 @@ export const routes = (app: HonoAppType) => ); }; + const onPermissionRequested = ( + event: InternalEventDeclaration["permissionRequested"], + ) => { + Effect.runFork( + typeSafeSSE.writeSSE("permissionRequested", { + permissionRequest: event.permissionRequest, + }), + ); + }; + yield* eventBus.on("sessionListChanged", onSessionListChanged); yield* eventBus.on("sessionChanged", onSessionChanged); yield* eventBus.on( @@ -655,6 +754,10 @@ export const routes = (app: HonoAppType) => onSessionProcessChanged, ); yield* eventBus.on("heartbeat", onHeartbeat); + yield* eventBus.on( + "permissionRequested", + onPermissionRequested, + ); const { connectionPromise } = adaptInternalEventToSSE( rawStream, @@ -676,6 +779,10 @@ export const routes = (app: HonoAppType) => onSessionProcessChanged, ); yield* eventBus.off("heartbeat", onHeartbeat); + yield* eventBus.off( + "permissionRequested", + onPermissionRequested, + ); }), ); }, diff --git a/src/server/service/claude-code/ClaudeCodeLifeCycleService.ts b/src/server/service/claude-code/ClaudeCodeLifeCycleService.ts index c269d0f..5a82f1b 100644 --- a/src/server/service/claude-code/ClaudeCodeLifeCycleService.ts +++ b/src/server/service/claude-code/ClaudeCodeLifeCycleService.ts @@ -118,7 +118,12 @@ const LayerImpl = Effect.gen(function* () { }, }); - const sessionInitializedPromise = controllablePromise(); + const sessionInitializedPromise = controllablePromise<{ + sessionId: string; + }>(); + const sessionFileCreatedPromise = controllablePromise<{ + sessionId: string; + }>(); setMessageGeneratorHooks({ onNewUserMessageResolved: async (message) => { @@ -194,7 +199,9 @@ const LayerImpl = Effect.gen(function* () { // do nothing } - sessionInitializedPromise.resolve(message.session_id); + sessionInitializedPromise.resolve({ + sessionId: message.session_id, + }); yield* eventBusService.emit("sessionListChanged", { projectId: processState.def.projectId, @@ -216,6 +223,10 @@ const LayerImpl = Effect.gen(function* () { sessionProcessId: processState.def.sessionProcessId, }); + sessionFileCreatedPromise.resolve({ + sessionId: message.session_id, + }); + yield* virtualConversationDatabase.deleteVirtualConversations( message.session_id, ); @@ -329,6 +340,8 @@ const LayerImpl = Effect.gen(function* () { daemonPromise, awaitSessionInitialized: async () => await sessionInitializedPromise.promise, + awaitSessionFileCreated: async () => + await sessionFileCreatedPromise.promise, }; }); }; diff --git a/src/server/service/claude-code/ClaudeCodeSessionProcessService.test.ts b/src/server/service/claude-code/ClaudeCodeSessionProcessService.test.ts index 63c34a9..123ee0a 100644 --- a/src/server/service/claude-code/ClaudeCodeSessionProcessService.test.ts +++ b/src/server/service/claude-code/ClaudeCodeSessionProcessService.test.ts @@ -1,4 +1,7 @@ -import type { SDKResultMessage, SDKSystemMessage } from "@anthropic-ai/claude-code"; +import type { + SDKResultMessage, + SDKSystemMessage, +} from "@anthropic-ai/claude-code"; import { Effect, Layer } from "effect"; import { describe, expect, it } from "vitest"; import { EventBus } from "../events/EventBus"; @@ -38,7 +41,6 @@ const createMockContinueTaskDef = ( baseSessionId, }); - // Helper function to create mock init context const createMockInitContext = (sessionId: string): InitMessageContext => ({ initMessage: { @@ -48,11 +50,12 @@ const createMockInitContext = (sessionId: string): InitMessageContext => ({ }); // Helper function to create mock result message -const createMockResultMessage = (sessionId: string): SDKResultMessage => ({ - type: "result", - session_id: sessionId, - result: {}, -} as SDKResultMessage); +const createMockResultMessage = (sessionId: string): SDKResultMessage => + ({ + type: "result", + session_id: sessionId, + result: {}, + }) as SDKResultMessage; // Mock EventBus for testing const MockEventBus = Layer.succeed(EventBus, { @@ -581,7 +584,9 @@ describe("ClaudeCodeSessionProcessService", () => { program.pipe(Effect.provide(TestLayer)), ); - const completedTask = process.tasks.find((t) => t.def.taskId === "task-1"); + const completedTask = process.tasks.find( + (t) => t.def.taskId === "task-1", + ); expect(completedTask?.status).toBe("completed"); if (completedTask?.status === "completed") { expect(completedTask.sessionId).toBe("session-1"); @@ -758,9 +763,7 @@ describe("ClaudeCodeSessionProcessService", () => { const program = Effect.gen(function* () { const service = yield* ClaudeCodeSessionProcessService; - const result = yield* Effect.flip( - service.getTask("non-existent-task"), - ); + const result = yield* Effect.flip(service.getTask("non-existent-task")); return result; }); @@ -857,7 +860,11 @@ describe("ClaudeCodeSessionProcessService", () => { }); // Continue with second task - const taskDef2 = createMockContinueTaskDef("task-2", "session-1", "session-1"); + const taskDef2 = createMockContinueTaskDef( + "task-2", + "session-1", + "session-1", + ); const continueResult = yield* service.continueSessionProcess({ sessionProcessId: "process-1", diff --git a/src/server/service/claude-code/computeClaudeProjectFilePath.test.ts b/src/server/service/claude-code/computeClaudeProjectFilePath.test.ts new file mode 100644 index 0000000..e7b8ddc --- /dev/null +++ b/src/server/service/claude-code/computeClaudeProjectFilePath.test.ts @@ -0,0 +1,47 @@ +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +describe("computeClaudeProjectFilePath", () => { + const TEST_GLOBAL_CLAUDE_DIR = "/test/mock/claude"; + const TEST_PROJECTS_DIR = path.join(TEST_GLOBAL_CLAUDE_DIR, "projects"); + + beforeEach(async () => { + vi.resetModules(); + vi.doMock("../../lib/env", () => ({ + env: { + get: (key: string) => { + if (key === "GLOBAL_CLAUDE_DIR") { + return TEST_GLOBAL_CLAUDE_DIR; + } + return undefined; + }, + }, + })); + }); + + it("プロジェクトパスからClaudeの設定ディレクトリパスを計算する", async () => { + const { computeClaudeProjectFilePath } = await import( + "./computeClaudeProjectFilePath" + ); + + const projectPath = "/home/me/dev/example"; + const expected = `${TEST_PROJECTS_DIR}/-home-me-dev-example`; + + const result = computeClaudeProjectFilePath(projectPath); + + expect(result).toBe(expected); + }); + + it("末尾にスラッシュがある場合も正しく処理される", async () => { + const { computeClaudeProjectFilePath } = await import( + "./computeClaudeProjectFilePath" + ); + + const projectPath = "/home/me/dev/example/"; + const expected = `${TEST_PROJECTS_DIR}/-home-me-dev-example`; + + const result = computeClaudeProjectFilePath(projectPath); + + expect(result).toBe(expected); + }); +}); diff --git a/src/server/service/claude-code/computeClaudeProjectFilePath.ts b/src/server/service/claude-code/computeClaudeProjectFilePath.ts new file mode 100644 index 0000000..e36fd9c --- /dev/null +++ b/src/server/service/claude-code/computeClaudeProjectFilePath.ts @@ -0,0 +1,9 @@ +import path from "node:path"; +import { claudeProjectsDirPath } from "../paths"; + +export function computeClaudeProjectFilePath(projectPath: string): string { + return path.join( + claudeProjectsDirPath, + projectPath.replace(/\/$/, "").replace(/\//g, "-"), + ); +} diff --git a/src/server/service/directory-browser/getDirectoryListing.test.ts b/src/server/service/directory-browser/getDirectoryListing.test.ts new file mode 100644 index 0000000..4f729fa --- /dev/null +++ b/src/server/service/directory-browser/getDirectoryListing.test.ts @@ -0,0 +1,151 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { getDirectoryListing } from "./getDirectoryListing"; + +describe("getDirectoryListing", () => { + let testDir: string; + + beforeEach(async () => { + testDir = join(tmpdir(), `test-dir-${Date.now()}`); + await mkdir(testDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + test("should list directories and files", async () => { + await mkdir(join(testDir, "subdir1")); + await mkdir(join(testDir, "subdir2")); + await writeFile(join(testDir, "file1.txt"), "content1"); + await writeFile(join(testDir, "file2.txt"), "content2"); + + const result = await getDirectoryListing(testDir); + + expect(result.entries).toHaveLength(4); + expect(result.entries).toEqual([ + { name: "subdir1", type: "directory", path: "subdir1" }, + { name: "subdir2", type: "directory", path: "subdir2" }, + { name: "file1.txt", type: "file", path: "file1.txt" }, + { name: "file2.txt", type: "file", path: "file2.txt" }, + ]); + expect(result.basePath).toBe("/"); + expect(result.currentPath).toBe(testDir); + }); + + test("should navigate to subdirectory", async () => { + await mkdir(join(testDir, "parent")); + await mkdir(join(testDir, "parent", "child")); + await writeFile(join(testDir, "parent", "file.txt"), "content"); + + const result = await getDirectoryListing(testDir, "parent"); + + expect(result.entries).toHaveLength(3); + expect(result.entries).toEqual([ + { name: "..", type: "directory", path: "" }, + { name: "child", type: "directory", path: "parent/child" }, + { name: "file.txt", type: "file", path: "parent/file.txt" }, + ]); + expect(result.basePath).toBe("parent"); + }); + + test("should skip hidden files and directories", async () => { + await mkdir(join(testDir, ".hidden-dir")); + await writeFile(join(testDir, ".hidden-file"), "content"); + await mkdir(join(testDir, "visible-dir")); + await writeFile(join(testDir, "visible-file.txt"), "content"); + + const result = await getDirectoryListing(testDir); + + expect(result.entries).toHaveLength(2); + expect(result.entries.some((e) => e.name.startsWith("."))).toBe(false); + }); + + test("should sort directories before files alphabetically", async () => { + await mkdir(join(testDir, "z-dir")); + await mkdir(join(testDir, "a-dir")); + await writeFile(join(testDir, "z-file.txt"), "content"); + await writeFile(join(testDir, "a-file.txt"), "content"); + + const result = await getDirectoryListing(testDir); + + expect(result.entries).toEqual([ + { name: "a-dir", type: "directory", path: "a-dir" }, + { name: "z-dir", type: "directory", path: "z-dir" }, + { name: "a-file.txt", type: "file", path: "a-file.txt" }, + { name: "z-file.txt", type: "file", path: "z-file.txt" }, + ]); + }); + + test("should return empty entries for non-existent directory", async () => { + const result = await getDirectoryListing(join(testDir, "non-existent")); + + expect(result.entries).toEqual([]); + expect(result.basePath).toBe("/"); + }); + + test("should prevent directory traversal", async () => { + await expect(getDirectoryListing(testDir, "../../../etc")).rejects.toThrow( + "Invalid path: outside root directory", + ); + }); + + test("should handle basePath with leading slash", async () => { + await mkdir(join(testDir, "subdir")); + await writeFile(join(testDir, "subdir", "file.txt"), "content"); + + const result = await getDirectoryListing(testDir, "/subdir"); + + expect(result.entries).toHaveLength(2); + expect(result.entries).toEqual([ + { name: "..", type: "directory", path: "" }, + { name: "file.txt", type: "file", path: "subdir/file.txt" }, + ]); + expect(result.basePath).toBe("subdir"); + }); + + test("should include parent directory entry when not at root", async () => { + await mkdir(join(testDir, "parent")); + await mkdir(join(testDir, "parent", "child")); + + const result = await getDirectoryListing(testDir, "parent"); + + const parentEntry = result.entries.find((e) => e.name === ".."); + expect(parentEntry).toEqual({ + name: "..", + type: "directory", + path: "", + }); + }); + + test("should not include parent directory entry at root", async () => { + await mkdir(join(testDir, "subdir")); + + const result = await getDirectoryListing(testDir); + + const parentEntry = result.entries.find((e) => e.name === ".."); + expect(parentEntry).toBeUndefined(); + }); + + test("should use absolute paths in currentPath for navigation", async () => { + await mkdir(join(testDir, "level1")); + await mkdir(join(testDir, "level1", "level2")); + + const rootResult = await getDirectoryListing(testDir); + expect(rootResult.currentPath).toBe(testDir); + + const level1Entry = rootResult.entries.find((e) => e.name === "level1"); + expect(level1Entry).toBeDefined(); + + const level1Result = await getDirectoryListing(testDir, level1Entry?.path); + expect(level1Result.currentPath).toBe(join(testDir, "level1")); + + const level2Entry = level1Result.entries.find((e) => e.name === "level2"); + expect(level2Entry).toBeDefined(); + + const level2Result = await getDirectoryListing(testDir, level2Entry?.path); + expect(level2Result.currentPath).toBe(join(testDir, "level1", "level2")); + }); +}); diff --git a/src/server/service/directory-browser/getDirectoryListing.ts b/src/server/service/directory-browser/getDirectoryListing.ts new file mode 100644 index 0000000..0017a96 --- /dev/null +++ b/src/server/service/directory-browser/getDirectoryListing.ts @@ -0,0 +1,100 @@ +import { existsSync } from "node:fs"; +import { readdir } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; + +export type DirectoryEntry = { + name: string; + type: "file" | "directory"; + path: string; +}; + +export type DirectoryListingResult = { + entries: DirectoryEntry[]; + basePath: string; + currentPath: string; +}; + +export const getDirectoryListing = async ( + rootPath: string, + basePath = "/", +): Promise => { + const normalizedBasePath = + basePath === "/" + ? "" + : basePath.startsWith("/") + ? basePath.slice(1) + : basePath; + const targetPath = resolve(rootPath, normalizedBasePath); + + if (!targetPath.startsWith(resolve(rootPath))) { + throw new Error("Invalid path: outside root directory"); + } + + if (!existsSync(targetPath)) { + return { + entries: [], + basePath: "/", + currentPath: rootPath, + }; + } + + try { + const dirents = await readdir(targetPath, { withFileTypes: true }); + const entries: DirectoryEntry[] = []; + + if (normalizedBasePath !== "") { + const parentPath = dirname(normalizedBasePath); + entries.push({ + name: "..", + type: "directory", + path: parentPath === "." ? "" : parentPath, + }); + } + + for (const dirent of dirents) { + if (dirent.name.startsWith(".")) { + continue; + } + + const entryPath = normalizedBasePath + ? join(normalizedBasePath, dirent.name) + : dirent.name; + + if (dirent.isDirectory()) { + entries.push({ + name: dirent.name, + type: "directory", + path: entryPath, + }); + } else if (dirent.isFile()) { + entries.push({ + name: dirent.name, + type: "file", + path: entryPath, + }); + } + } + + entries.sort((a, b) => { + if (a.name === "..") return -1; + if (b.name === "..") return 1; + if (a.type !== b.type) { + return a.type === "directory" ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + + return { + entries, + basePath: normalizedBasePath || "/", + currentPath: targetPath, + }; + } catch (error) { + console.error("Error reading directory:", error); + return { + entries: [], + basePath: normalizedBasePath || "/", + currentPath: targetPath, + }; + } +}; diff --git a/src/server/service/events/fileWatcher.ts b/src/server/service/events/fileWatcher.ts index fb9f104..ac7d045 100644 --- a/src/server/service/events/fileWatcher.ts +++ b/src/server/service/events/fileWatcher.ts @@ -1,7 +1,9 @@ import { type FSWatcher, watch } from "node:fs"; +import { join } from "node:path"; import { Context, Effect, Layer, Ref } from "effect"; import z from "zod"; import { claudeProjectsDirPath } from "../paths"; +import { encodeProjectIdFromSessionFilePath } from "../project/id"; import { EventBus } from "./EventBus"; const fileRegExp = /(?.*?)\/(?.*?)\.jsonl/; @@ -54,8 +56,13 @@ export class FileWatcherService extends Context.Tag("FileWatcherService")< if (!groups.success) return; - const { projectId, sessionId } = groups.data; - const debounceKey = `${projectId}/${sessionId}`; + const { sessionId } = groups.data; + + // フルパスを構築してエンコードされた projectId を取得 + const fullPath = join(claudeProjectsDirPath, filename); + const encodedProjectId = + encodeProjectIdFromSessionFilePath(fullPath); + const debounceKey = `${encodedProjectId}/${sessionId}`; Effect.runPromise( Effect.gen(function* () { @@ -68,14 +75,14 @@ export class FileWatcherService extends Context.Tag("FileWatcherService")< const newTimer = setTimeout(() => { Effect.runFork( eventBus.emit("sessionChanged", { - projectId, + projectId: encodedProjectId, sessionId, }), ); Effect.runFork( eventBus.emit("sessionListChanged", { - projectId, + projectId: encodedProjectId, }), ); diff --git a/src/server/service/git/getCommits.test.ts b/src/server/service/git/getCommits.test.ts index 580e5ae..018cd23 100644 --- a/src/server/service/git/getCommits.test.ts +++ b/src/server/service/git/getCommits.test.ts @@ -22,7 +22,6 @@ describe("getCommits", () => { def456|fix: bug fix|Jane Smith|2024-01-14 09:20:00 +0900 ghi789|chore: update deps|Bob Johnson|2024-01-13 08:10:00 +0900`; - vi.mocked(utils.executeGitCommand).mockResolvedValue({ success: true, data: mockOutput, @@ -70,7 +69,6 @@ ghi789|chore: update deps|Bob Johnson|2024-01-13 08:10:00 +0900`; const mockCwd = "/test/repo"; const mockOutput = ""; - vi.mocked(utils.executeGitCommand).mockResolvedValue({ success: true, data: mockOutput, @@ -92,7 +90,6 @@ def456|fix: bug fix|Jane Smith|2024-01-14 09:20:00 +0900 ||missing data| ghi789|chore: update deps|Bob Johnson|2024-01-13 08:10:00 +0900`; - vi.mocked(utils.executeGitCommand).mockResolvedValue({ success: true, data: mockOutput, @@ -156,7 +153,6 @@ ghi789|chore: update deps|Bob Johnson|2024-01-13 08:10:00 +0900`; it("Gitコマンドが失敗した場合", async () => { const mockCwd = "/test/repo"; - vi.mocked(utils.executeGitCommand).mockResolvedValue({ success: false, error: { @@ -206,7 +202,6 @@ def456|fix: 日本語メッセージ|日本語 著者|2024-01-14 09:20:00 +0900` const mockCwd = "/test/my repo with spaces"; const mockOutput = `abc123|feat: test|Author|2024-01-15 10:30:00 +0900`; - vi.mocked(utils.executeGitCommand).mockResolvedValue({ success: true, data: mockOutput, @@ -233,7 +228,6 @@ def456|fix: 日本語メッセージ|日本語 著者|2024-01-14 09:20:00 +0900` def456|fix: bug|Author|2024-01-14 09:20:00 +0900 `; - vi.mocked(utils.executeGitCommand).mockResolvedValue({ success: true, data: mockOutput, diff --git a/src/server/service/project/ProjectRepository.ts b/src/server/service/project/ProjectRepository.ts index 2222d5f..024b0df 100644 --- a/src/server/service/project/ProjectRepository.ts +++ b/src/server/service/project/ProjectRepository.ts @@ -1,109 +1,111 @@ import { resolve } from "node:path"; import { FileSystem } from "@effect/platform"; import { Context, Effect, Layer, Option } from "effect"; +import type { InferEffect } from "../../lib/effect/types"; import { claudeProjectsDirPath } from "../paths"; import type { Project } from "../types"; import { decodeProjectId, encodeProjectId } from "./id"; import { ProjectMetaService } from "./ProjectMetaService"; -const getProject = (projectId: string) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const projectMetaService = yield* ProjectMetaService; +const LayerImpl = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const projectMetaService = yield* ProjectMetaService; - const fullPath = decodeProjectId(projectId); + const getProject = (projectId: string) => + Effect.gen(function* () { + const fullPath = decodeProjectId(projectId); - // Check if project directory exists - const exists = yield* fs.exists(fullPath); - if (!exists) { - return yield* Effect.fail(new Error("Project not found")); - } + // Check if project directory exists + const exists = yield* fs.exists(fullPath); + if (!exists) { + return yield* Effect.fail(new Error("Project not found")); + } - // Get file stats - const stat = yield* fs.stat(fullPath); + // Get file stats + const stat = yield* fs.stat(fullPath); - // Get project metadata - const meta = yield* projectMetaService.getProjectMeta(projectId); + // Get project metadata + const meta = yield* projectMetaService.getProjectMeta(projectId); - return { - project: { - id: projectId, - claudeProjectPath: fullPath, - lastModifiedAt: Option.getOrElse(stat.mtime, () => new Date()), - meta, - }, - }; - }); - -const getProjects = () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const projectMetaService = yield* ProjectMetaService; - - // Check if the claude projects directory exists - const dirExists = yield* fs.exists(claudeProjectsDirPath); - if (!dirExists) { - console.warn( - `Claude projects directory not found at ${claudeProjectsDirPath}`, - ); - return { projects: [] }; - } - - // Read directory entries - const entries = yield* fs.readDirectory(claudeProjectsDirPath); - - // Filter directories and map to Project objects - const projectEffects = entries.map((entry) => - Effect.gen(function* () { - const fullPath = resolve(claudeProjectsDirPath, entry); - - // Check if it's a directory - const stat = yield* Effect.tryPromise(() => - fs.stat(fullPath).pipe(Effect.runPromise), - ).pipe(Effect.catchAll(() => Effect.succeed(null))); - - if (!stat || stat.type !== "Directory") { - return null; - } - - const id = encodeProjectId(fullPath); - const meta = yield* projectMetaService.getProjectMeta(id); - - return { - id, + return { + project: { + id: projectId, claudeProjectPath: fullPath, lastModifiedAt: Option.getOrElse(stat.mtime, () => new Date()), meta, - } satisfies Project; - }), - ); - - // Execute all effects in parallel and filter out nulls - const projectsWithNulls = yield* Effect.all(projectEffects, { - concurrency: "unbounded", + }, + }; }); - const projects = projectsWithNulls.filter((p): p is Project => p !== null); - // Sort by last modified date (newest first) - const sortedProjects = projects.sort((a, b) => { - return ( - (b.lastModifiedAt ? b.lastModifiedAt.getTime() : 0) - - (a.lastModifiedAt ? a.lastModifiedAt.getTime() : 0) + const getProjects = () => + Effect.gen(function* () { + // Check if the claude projects directory exists + const dirExists = yield* fs.exists(claudeProjectsDirPath); + if (!dirExists) { + console.warn( + `Claude projects directory not found at ${claudeProjectsDirPath}`, + ); + return { projects: [] }; + } + + // Read directory entries + const entries = yield* fs.readDirectory(claudeProjectsDirPath); + + // Filter directories and map to Project objects + const projectEffects = entries.map((entry) => + Effect.gen(function* () { + const fullPath = resolve(claudeProjectsDirPath, entry); + + // Check if it's a directory + const stat = yield* Effect.tryPromise(() => + fs.stat(fullPath).pipe(Effect.runPromise), + ).pipe(Effect.catchAll(() => Effect.succeed(null))); + + if (!stat || stat.type !== "Directory") { + return null; + } + + const id = encodeProjectId(fullPath); + const meta = yield* projectMetaService.getProjectMeta(id); + + return { + id, + claudeProjectPath: fullPath, + lastModifiedAt: Option.getOrElse(stat.mtime, () => new Date()), + meta, + } satisfies Project; + }), ); + + // Execute all effects in parallel and filter out nulls + const projectsWithNulls = yield* Effect.all(projectEffects, { + concurrency: "unbounded", + }); + const projects = projectsWithNulls.filter( + (p): p is Project => p !== null, + ); + + // Sort by last modified date (newest first) + const sortedProjects = projects.sort((a, b) => { + return ( + (b.lastModifiedAt ? b.lastModifiedAt.getTime() : 0) - + (a.lastModifiedAt ? a.lastModifiedAt.getTime() : 0) + ); + }); + + return { projects: sortedProjects }; }); - return { projects: sortedProjects }; - }); - -export class ProjectRepository extends Context.Tag("ProjectRepository")< - ProjectRepository, - { - readonly getProject: typeof getProject; - readonly getProjects: typeof getProjects; - } ->() { - static Live = Layer.succeed(this, { + return { getProject, getProjects, - }); + }; +}); + +export type IProjectRepository = InferEffect; +export class ProjectRepository extends Context.Tag("ProjectRepository")< + ProjectRepository, + IProjectRepository +>() { + static Live = Layer.effect(this, LayerImpl); } diff --git a/src/types/sse.ts b/src/types/sse.ts index 2ab4afc..8bfe263 100644 --- a/src/types/sse.ts +++ b/src/types/sse.ts @@ -21,7 +21,7 @@ export type SSEEventDeclaration = { processes: PublicSessionProcess[]; }; - permission_requested: { + permissionRequested: { permissionRequest: PermissionRequest; }; };